mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-07 23:25:51 -08:00
Compare commits
1366 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
98a038e39e | ||
|
|
33477202b9 | ||
|
|
9c74d648f8 | ||
|
|
feb2e0be03 | ||
|
|
84e76eadd9 | ||
|
|
c1a73e7839 | ||
|
|
75625b143c | ||
|
|
c10e17d24c | ||
|
|
21d465bcb8 | ||
|
|
47c1300f30 | ||
|
|
e7d8149d74 | ||
|
|
a3220ac72d | ||
|
|
994621372c | ||
|
|
9d3cbb19f9 | ||
|
|
a8694cfb79 | ||
|
|
0968730382 | ||
|
|
3110763052 | ||
|
|
71e5348cbb | ||
|
|
5b399bff89 | ||
|
|
b7ff5d9a57 | ||
|
|
bfa4d06ecf | ||
|
|
d45bbb89b9 | ||
|
|
40805ee870 | ||
|
|
385f41d461 | ||
|
|
6f12ed38d9 | ||
|
|
efb4e5a7b3 | ||
|
|
a15689e380 | ||
|
|
548d893eaa | ||
|
|
1ec9ab5568 | ||
|
|
a767d7723c | ||
|
|
a60c6176be | ||
|
|
83cfd6ec05 | ||
|
|
f673dfb7cf | ||
|
|
22d8b0ef30 | ||
|
|
763edf00f2 | ||
|
|
b7128e6ee2 | ||
|
|
db56f4a6b7 | ||
|
|
3fa253bac5 | ||
|
|
52d8da16f6 | ||
|
|
fc210c2d18 | ||
|
|
56ef918a10 | ||
|
|
d7509972e4 | ||
|
|
49a0f473ce | ||
|
|
520e5feefb | ||
|
|
0992087e9a | ||
|
|
246a5c568b | ||
|
|
ac02019930 | ||
|
|
5121b0d09b | ||
|
|
c083716627 | ||
|
|
31c15c257c | ||
|
|
dcb6da30ef | ||
|
|
c46abd7e65 | ||
|
|
f478b65815 | ||
|
|
8363d1749b | ||
|
|
b3ae4b86e4 | ||
|
|
6566dde8d0 | ||
|
|
7b0b243607 | ||
|
|
d768379a8a | ||
|
|
5e84900ac4 | ||
|
|
73ae180437 | ||
|
|
2097164d32 | ||
|
|
9f0a8e6d48 | ||
|
|
5ca737886b | ||
|
|
11285fb0aa | ||
|
|
82de3c95e2 | ||
|
|
b0bf66bdcb | ||
|
|
8af5855af6 | ||
|
|
383d0f1a66 | ||
|
|
4dfa1e3227 | ||
|
|
1a63ed970a | ||
|
|
5b5d96971e | ||
|
|
71767e8b79 | ||
|
|
bd0b7ea80a | ||
|
|
744b12345a | ||
|
|
2770014988 | ||
|
|
31b93dc2f4 | ||
|
|
81397936ef | ||
|
|
722af0a3ca | ||
|
|
6641b13511 | ||
|
|
5a03c0edd6 | ||
|
|
9dbafd3b4b | ||
|
|
1f5d1532e3 | ||
|
|
1f61d8322c | ||
|
|
0c27dbe746 | ||
|
|
a3951c2621 | ||
|
|
c381df6563 | ||
|
|
39ff471772 | ||
|
|
33c8d307ed | ||
|
|
26b336d6db | ||
|
|
fbd5bfd382 | ||
|
|
e0d6503590 | ||
|
|
b10d9040df | ||
|
|
415f045fd8 | ||
|
|
f4e34372be | ||
|
|
50264993b0 | ||
|
|
45a6598d18 | ||
|
|
b205972e44 | ||
|
|
3d19c39001 | ||
|
|
428177bdca | ||
|
|
c21bd11b66 | ||
|
|
beb4949044 | ||
|
|
1b4659276c | ||
|
|
affd707717 | ||
|
|
48ed394d02 | ||
|
|
4f00f5509f | ||
|
|
47c5c407ef | ||
|
|
a27d09f81a | ||
|
|
2fb765455c | ||
|
|
639e6f9a6c | ||
|
|
3e40de72b2 | ||
|
|
686812ee9e | ||
|
|
80c3b8bbca | ||
|
|
824b932961 | ||
|
|
7c3ba3bc42 | ||
|
|
c638a2cfb6 | ||
|
|
6e29101ecf | ||
|
|
6b4445e122 | ||
|
|
f7e89695e5 | ||
|
|
9cb24280fa | ||
|
|
cf20c0781f | ||
|
|
cd1c38515b | ||
|
|
a5ca4f1611 | ||
|
|
fc022c98f2 | ||
|
|
52aebc3094 | ||
|
|
2ef60c0cd9 | ||
|
|
10411466d8 | ||
|
|
a6cfed0da2 | ||
|
|
5d29184801 | ||
|
|
f4762cb3f2 | ||
|
|
899e9331fa | ||
|
|
cc3d5e60a1 | ||
|
|
97f6003582 | ||
|
|
b217e734cb | ||
|
|
09fb956ba6 | ||
|
|
d8dedbe7fa | ||
|
|
b07345cee7 | ||
|
|
4709902819 | ||
|
|
af9ab30bdf | ||
|
|
f5e82c0417 | ||
|
|
a9f6317032 | ||
|
|
cf09c2aa3d | ||
|
|
a53d4219b3 | ||
|
|
bd8e1f6531 | ||
|
|
3658c9f8e3 | ||
|
|
6a912c128d | ||
|
|
71f30b72f4 | ||
|
|
2dc8b77ddc | ||
|
|
16cd2760a4 | ||
|
|
55bfc71269 | ||
|
|
d623cd5ce0 | ||
|
|
4bbf8858b0 | ||
|
|
5626ff1582 | ||
|
|
28f5236719 | ||
|
|
f9e1db41e9 | ||
|
|
61ffdff207 | ||
|
|
3bcd85aa0a | ||
|
|
8b60a9e2f0 | ||
|
|
4cd9711de3 | ||
|
|
2ffa0d0e7f | ||
|
|
586af0de1d | ||
|
|
e90b2c3a5c | ||
|
|
3d6c82861a | ||
|
|
fc3b8c40be | ||
|
|
54cd32872e | ||
|
|
c178006acc | ||
|
|
4e43166e1f | ||
|
|
452026165f | ||
|
|
82b8b313f0 | ||
|
|
b529f95798 | ||
|
|
2d55cf4bbf | ||
|
|
62e0e0bb55 | ||
|
|
83a40d4394 | ||
|
|
4937156021 | ||
|
|
24596899c9 | ||
|
|
cd3f0eabfb | ||
|
|
34af785e87 | ||
|
|
34cfe7d1df | ||
|
|
2f9e530fd8 | ||
|
|
ca8f6c2439 | ||
|
|
4a8ba0575f | ||
|
|
77ec8d4141 | ||
|
|
61ae51b30c | ||
|
|
f26d2d5f20 | ||
|
|
fd07bc3f2c | ||
|
|
8316a1902d | ||
|
|
650fd5d792 | ||
|
|
82d3e4bc92 | ||
|
|
8eb1f0258c | ||
|
|
80c86f34a4 | ||
|
|
3ed7b9f60c | ||
|
|
77c18ac819 | ||
|
|
0d6c23e4f2 | ||
|
|
ec9ef21cc0 | ||
|
|
43323e59ce | ||
|
|
9ada4df151 | ||
|
|
d42d77d3d3 | ||
|
|
2007549e01 | ||
|
|
987bbc761a | ||
|
|
0b096528d4 | ||
|
|
fa56541b3a | ||
|
|
beb15aa99a | ||
|
|
ca9bf48ffa | ||
|
|
b9941e40c1 | ||
|
|
e8639988ce | ||
|
|
c32f3d6e96 | ||
|
|
60697cc8ba | ||
|
|
c0d3f140f3 | ||
|
|
d5934a88a7 | ||
|
|
db2731dfb7 | ||
|
|
97ee73d79f | ||
|
|
48ce19a923 | ||
|
|
4f28c3fa46 | ||
|
|
449f4ee92f | ||
|
|
79041bdf21 | ||
|
|
655d14ed6e | ||
|
|
5d0d9c2890 | ||
|
|
f10163e7d2 | ||
|
|
666e3b5333 | ||
|
|
2b124aaff4 | ||
|
|
00d62fc23f | ||
|
|
aa87b78dde | ||
|
|
6c71bd40fb | ||
|
|
ed40043448 | ||
|
|
5cf7e6e24b | ||
|
|
720ef936da | ||
|
|
30755b2067 | ||
|
|
04f67c114e | ||
|
|
ea707a0bc5 | ||
|
|
f43475f33b | ||
|
|
739d4d0038 | ||
|
|
e756a77c70 | ||
|
|
bcfa5d0a7e | ||
|
|
45f92536a6 | ||
|
|
6b0b78d8e0 | ||
|
|
c336cdc5df | ||
|
|
6ea8d07c8f | ||
|
|
5c25a08dc1 | ||
|
|
fe7f109127 | ||
|
|
583819c4ae | ||
|
|
cb8da2e757 | ||
|
|
fdc96115e4 | ||
|
|
e019ec5ff7 | ||
|
|
e4838f6d2b | ||
|
|
10837e75b2 | ||
|
|
46590c3163 | ||
|
|
e64d5c5f17 | ||
|
|
0e0cc0ad16 | ||
|
|
8ff01ca979 | ||
|
|
9508a9afc6 | ||
|
|
704a0e3078 | ||
|
|
9bf9f2c611 | ||
|
|
71c869e65b | ||
|
|
2897fa4003 | ||
|
|
7f020857d1 | ||
|
|
2217a9304d | ||
|
|
5a389b4855 | ||
|
|
bdb9b7803c | ||
|
|
4622b3fe36 | ||
|
|
402afd15db | ||
|
|
82aca3bce4 | ||
|
|
756c6554c9 | ||
|
|
3b9753aaf4 | ||
|
|
4472ef20fe | ||
|
|
c152790011 | ||
|
|
4e3b8a5178 | ||
|
|
375a0ff208 | ||
|
|
57831f0eba | ||
|
|
c9a3f67121 | ||
|
|
6af1f98c88 | ||
|
|
8e35372aad | ||
|
|
0f4d285223 | ||
|
|
192e592cda | ||
|
|
1c2c1f286f | ||
|
|
6e25af9493 | ||
|
|
050927008a | ||
|
|
2fe5459c56 | ||
|
|
8fbbaf7fcb | ||
|
|
2f5bdc5cf9 | ||
|
|
17833a0bfc | ||
|
|
f4e71df946 | ||
|
|
be070b79af | ||
|
|
ef8eefd3b4 | ||
|
|
83f46f6b2b | ||
|
|
6b4bdf569c | ||
|
|
7a9f6e2a8e | ||
|
|
ce95ff65bd | ||
|
|
28e724da98 | ||
|
|
a43b027cde | ||
|
|
4b5e36ebf2 | ||
|
|
89c05cfcae | ||
|
|
f8569db21b | ||
|
|
34eba2655e | ||
|
|
1625860bd9 | ||
|
|
f3ddfb96f3 | ||
|
|
66e198cbb6 | ||
|
|
33c747a881 | ||
|
|
20d61d14e0 | ||
|
|
833de94ab0 | ||
|
|
c8d6250ada | ||
|
|
d38e1185bb | ||
|
|
fdb8ae0cb5 | ||
|
|
b57306beac | ||
|
|
af6e159644 | ||
|
|
54e50f69e1 | ||
|
|
3f415b8265 | ||
|
|
8ccdb56bf1 | ||
|
|
17ed957c6b | ||
|
|
e4564abe41 | ||
|
|
f16b29b16b | ||
|
|
ef8af7d618 | ||
|
|
79e33899a8 | ||
|
|
11fc220d4d | ||
|
|
a94a30168c | ||
|
|
19704920a4 | ||
|
|
e4f4c1f1be | ||
|
|
065931cae7 | ||
|
|
78443bffac | ||
|
|
a8b105267c | ||
|
|
f7bd637073 | ||
|
|
3e6f7f0fad | ||
|
|
e301b67e49 | ||
|
|
952d878442 | ||
|
|
8f66f94ffa | ||
|
|
d79acef59e | ||
|
|
e66a2a7c30 | ||
|
|
2f04b93fdb | ||
|
|
818e99b39d | ||
|
|
96ffe95404 | ||
|
|
438e53d25e | ||
|
|
ca4b0acd92 | ||
|
|
f8deb1bd7f | ||
|
|
d8de84e417 | ||
|
|
eb602aedc3 | ||
|
|
b539892cc0 | ||
|
|
ba13d2179d | ||
|
|
c7a315ac97 | ||
|
|
b1fb793ea4 | ||
|
|
62db9ad982 | ||
|
|
652c9943c2 | ||
|
|
9f62575abe | ||
|
|
2fd87f703e | ||
|
|
d3780cd9d5 | ||
|
|
0376705e47 | ||
|
|
f1fddac655 | ||
|
|
6acd08431e | ||
|
|
317f7116c4 | ||
|
|
bf8e99140e | ||
|
|
6c949c3a52 | ||
|
|
76d591bab5 | ||
|
|
d10cab824a | ||
|
|
a93d633d25 | ||
|
|
9ebab4a382 | ||
|
|
cd53dcfe43 | ||
|
|
87ceef230f | ||
|
|
a06e81a0ba | ||
|
|
59e87e0d27 | ||
|
|
76d1460d0f | ||
|
|
1985423a97 | ||
|
|
f5afc84cd2 | ||
|
|
1217179f8a | ||
|
|
29a207b73e | ||
|
|
f7ecf02beb | ||
|
|
c5193ffdd9 | ||
|
|
916ba2ea41 | ||
|
|
3348dce122 | ||
|
|
53e6ca6e34 | ||
|
|
0fed7f1295 | ||
|
|
6ade832029 | ||
|
|
50ba9a56f7 | ||
|
|
990141df47 | ||
|
|
50f7541ef7 | ||
|
|
6a6962b3b9 | ||
|
|
3314ad0315 | ||
|
|
9a4a96eedd | ||
|
|
ff4a9d1761 | ||
|
|
df2d4a557e | ||
|
|
d831923a54 | ||
|
|
594183d751 | ||
|
|
bddaa954ab | ||
|
|
f4a7777018 | ||
|
|
8fc9a9c55e | ||
|
|
8e457d9b8f | ||
|
|
aa37c9bf81 | ||
|
|
89cbd05600 | ||
|
|
a5e9c4af03 | ||
|
|
ea753cd8bf | ||
|
|
46e9fd7ae3 | ||
|
|
96d7277a22 | ||
|
|
c937167a11 | ||
|
|
0c59ad7e22 | ||
|
|
fa1b93252c | ||
|
|
0d9e186e18 | ||
|
|
b7aa5a17b7 | ||
|
|
d55a057a4d | ||
|
|
72976da3a4 | ||
|
|
81afbb55cf | ||
|
|
d1709764ef | ||
|
|
4f7e3d7a45 | ||
|
|
4ca53a6ee0 | ||
|
|
efe02e2591 | ||
|
|
391f42b4f2 | ||
|
|
cff5db446d | ||
|
|
858d4c74ce | ||
|
|
f56bf0db73 | ||
|
|
4801bb1178 | ||
|
|
8b2433584d | ||
|
|
bde02f696b | ||
|
|
0afbe7988e | ||
|
|
345d4c58f3 | ||
|
|
6c44ffaf7a | ||
|
|
16454dbc33 | ||
|
|
89c6fd6ac4 | ||
|
|
ea8b6e6438 | ||
|
|
c0b25e1f6e | ||
|
|
df0335f739 | ||
|
|
1ffe5fc7bb | ||
|
|
cf070e6dd9 | ||
|
|
f9a9189687 | ||
|
|
9daf1abcd9 | ||
|
|
8c525a5e33 | ||
|
|
952a155003 | ||
|
|
7f35f6f8f4 | ||
|
|
8b9e278593 | ||
|
|
655ebcdb07 | ||
|
|
ac534a6881 | ||
|
|
59529eba4e | ||
|
|
1cef10b309 | ||
|
|
c3070be14a | ||
|
|
5570440eb1 | ||
|
|
ec0a5df5a1 | ||
|
|
8411b76ee5 | ||
|
|
c0ff90fc86 | ||
|
|
f9c8816c43 | ||
|
|
822e8941ed | ||
|
|
7ac9bd8591 | ||
|
|
68a5784650 | ||
|
|
67f324b939 | ||
|
|
8db8c60e75 | ||
|
|
8e569a1d1f | ||
|
|
3caf8bc82b | ||
|
|
3da028415f | ||
|
|
104df1915d | ||
|
|
bfb6d44195 | ||
|
|
df0e8bc027 | ||
|
|
442b6ced35 | ||
|
|
111e11924f | ||
|
|
061cc69a6a | ||
|
|
f9950e1f01 | ||
|
|
895d259589 | ||
|
|
4ea80f34fa | ||
|
|
77878bf714 | ||
|
|
f85dde6323 | ||
|
|
6441f92c9f | ||
|
|
25b9fc8b6a | ||
|
|
090678776e | ||
|
|
9be6d443d7 | ||
|
|
678253d037 | ||
|
|
bd561fd191 | ||
|
|
38b5ee7314 | ||
|
|
11245462f0 | ||
|
|
351a5b87bf | ||
|
|
b780257098 | ||
|
|
4e1f1551ea | ||
|
|
b82e3f2a8a | ||
|
|
a82bf1bb32 | ||
|
|
abc0220cfa | ||
|
|
f17e6f9afd | ||
|
|
16e6b9eed7 | ||
|
|
323415ba9c | ||
|
|
ae97b5e704 | ||
|
|
6b8b30c3c7 | ||
|
|
0df2b2221d | ||
|
|
e2b36dfa7d | ||
|
|
4e18f24f3b | ||
|
|
b0d5a51768 | ||
|
|
b3d2c22373 | ||
|
|
cace88e8fa | ||
|
|
9c09d84c71 | ||
|
|
2d27665369 | ||
|
|
45266caa8d | ||
|
|
feb1a59902 | ||
|
|
fdec4157da | ||
|
|
4e84b20925 | ||
|
|
f952ad5913 | ||
|
|
be27586203 | ||
|
|
9dc3f3f38b | ||
|
|
f39defbe06 | ||
|
|
890f71a477 | ||
|
|
bc8e8c5daf | ||
|
|
37f12809a1 | ||
|
|
f5c0b847a9 | ||
|
|
44d6c3c07e | ||
|
|
da1a2b2957 | ||
|
|
9f6fa2bd05 | ||
|
|
5d68dc568f | ||
|
|
ee1ea881e8 | ||
|
|
87add88436 | ||
|
|
7643609e09 | ||
|
|
73727ab0d1 | ||
|
|
007a393ab5 | ||
|
|
4ed185a155 | ||
|
|
fbb220ce85 | ||
|
|
0c57d35402 | ||
|
|
8cc045f370 | ||
|
|
80c90c0a00 | ||
|
|
c1c92647ca | ||
|
|
033adceb6f | ||
|
|
e57e92bfee | ||
|
|
4d68000692 | ||
|
|
44b5423afc | ||
|
|
a1a7729c3b | ||
|
|
071b0eeb77 | ||
|
|
fafc17c7d3 | ||
|
|
7599302920 | ||
|
|
7f8d7231a4 | ||
|
|
b1196885d7 | ||
|
|
494cfb3c04 | ||
|
|
6a65981103 | ||
|
|
f508f93d69 | ||
|
|
411d4434a3 | ||
|
|
d41fce6f91 | ||
|
|
282e7b4006 | ||
|
|
b4c3c5deea | ||
|
|
683514d891 | ||
|
|
e9beb21a98 | ||
|
|
bc47f78264 | ||
|
|
b002f7f862 | ||
|
|
05dac999a8 | ||
|
|
242595b725 | ||
|
|
48dd1a1aa6 | ||
|
|
3aacaffe6b | ||
|
|
61b875256f | ||
|
|
14dc450631 | ||
|
|
6352056528 | ||
|
|
bd4f24844b | ||
|
|
062615b6b1 | ||
|
|
6c9293e4f6 | ||
|
|
24802d64c7 | ||
|
|
5e8a686bb6 | ||
|
|
279ab89a61 | ||
|
|
29ed40051d | ||
|
|
8d05aa6262 | ||
|
|
694f942c06 | ||
|
|
105a2d4e13 | ||
|
|
1ee62912fd | ||
|
|
abacca34ee | ||
|
|
3e4e69735e | ||
|
|
4afc351933 | ||
|
|
23b8070b9d | ||
|
|
e53b5324f5 | ||
|
|
25bbbdbecd | ||
|
|
d739d04380 | ||
|
|
f7da0265c4 | ||
|
|
82ae21420d | ||
|
|
89984a0d09 | ||
|
|
fc62b4e0bd | ||
|
|
2e2ca1665b | ||
|
|
1b27fc495f | ||
|
|
51c38fc628 | ||
|
|
74c30ce09a | ||
|
|
859316353e | ||
|
|
63c9bea724 | ||
|
|
df435eb693 | ||
|
|
c73b994305 | ||
|
|
88451d4239 | ||
|
|
f74db254f6 | ||
|
|
3cb0a22e17 | ||
|
|
ca3e01b15e | ||
|
|
e9d1dcc46c | ||
|
|
7fd0f1a5bf | ||
|
|
2d65fbf798 | ||
|
|
ac915d00fc | ||
|
|
fbb8d6b132 | ||
|
|
fb0f70b3e3 | ||
|
|
17929415ee | ||
|
|
631b6788c6 | ||
|
|
7972aa6320 | ||
|
|
138c884684 | ||
|
|
f5ef98287a | ||
|
|
5188b41ab0 | ||
|
|
f83ba6e615 | ||
|
|
cc2a72eb82 | ||
|
|
4fcce66505 | ||
|
|
66627d8a66 | ||
|
|
adfd68f83c | ||
|
|
ddc619f2e7 | ||
|
|
ff2e57705e | ||
|
|
a6a859b272 | ||
|
|
88c5ebdd2f | ||
|
|
3d578bcc98 | ||
|
|
c3290af2bd | ||
|
|
01f1545b3e | ||
|
|
fc8e849db5 | ||
|
|
9115e59f15 | ||
|
|
2f4b248a45 | ||
|
|
2f28afb46e | ||
|
|
e960d7b58c | ||
|
|
321569c542 | ||
|
|
df037c54ff | ||
|
|
d859cecffb | ||
|
|
fd6e009c4b | ||
|
|
4520051ec9 | ||
|
|
b90b73859a | ||
|
|
6c357b61cc | ||
|
|
12957db90f | ||
|
|
3c74f561d5 | ||
|
|
cc70a6fa26 | ||
|
|
1c42564d90 | ||
|
|
e76c870c09 | ||
|
|
5daadcb2d5 | ||
|
|
a124a7a82a | ||
|
|
a65bf60cea | ||
|
|
3fa28a3fdb | ||
|
|
baa7992a7a | ||
|
|
7ba4bfc0d5 | ||
|
|
11fedef2f5 | ||
|
|
944347a2b3 | ||
|
|
8c72b0a6c4 | ||
|
|
5d62d4e063 | ||
|
|
9b05537a0e | ||
|
|
fd0a87626e | ||
|
|
9402d82405 | ||
|
|
da6674760c | ||
|
|
ee03371dd0 | ||
|
|
a975c8fd00 | ||
|
|
60840da740 | ||
|
|
de567cc701 | ||
|
|
de4775b0c8 | ||
|
|
104cc0ea83 | ||
|
|
5bb8de500a | ||
|
|
21255b3b46 | ||
|
|
e8da9924c6 | ||
|
|
96b38aba04 | ||
|
|
b8b51965d2 | ||
|
|
be46d128bc | ||
|
|
c05f1ed24f | ||
|
|
99775ec1bd | ||
|
|
f4f043ac87 | ||
|
|
acbca78e2d | ||
|
|
30ac7baa2c | ||
|
|
21a5170337 | ||
|
|
3a5a6a096b | ||
|
|
578ae70150 | ||
|
|
57282e76a4 | ||
|
|
7aaa652ef5 | ||
|
|
81da0d2ba4 | ||
|
|
ce6cdcaf92 | ||
|
|
4730a928b5 | ||
|
|
4c0f0a16c9 | ||
|
|
b07fc80f3f | ||
|
|
6a3d1fcaf4 | ||
|
|
4aeb3cd3dc | ||
|
|
6dc2000638 | ||
|
|
72610d8c2f | ||
|
|
0f55fa4f45 | ||
|
|
aec39c919c | ||
|
|
a0849f9416 | ||
|
|
0668f94461 | ||
|
|
953ccc55d9 | ||
|
|
fbaa8226c4 | ||
|
|
8abfd14569 | ||
|
|
f2f4d6a133 | ||
|
|
3ed7092af5 | ||
|
|
9d6fa855d8 | ||
|
|
8c7404edf9 | ||
|
|
3f6a9e5dc7 | ||
|
|
9e1748bf67 | ||
|
|
527a9b49e2 | ||
|
|
b187223162 | ||
|
|
2c5e99efed | ||
|
|
fa8531022d | ||
|
|
8d4be10fd7 | ||
|
|
285b9e12eb | ||
|
|
53fcb86174 | ||
|
|
a532ceeb0a | ||
|
|
9ec0680ce5 | ||
|
|
299036ecca | ||
|
|
4bfeb77a3a | ||
|
|
ab7a5b07eb | ||
|
|
50ad661796 | ||
|
|
d3e71ecb9b | ||
|
|
ba3bb201cd | ||
|
|
01d88c362a | ||
|
|
95350a1fa9 | ||
|
|
cc458ca5b1 | ||
|
|
f19878fcb8 | ||
|
|
eb8e8691e9 | ||
|
|
0423c22d7f | ||
|
|
3441c390bd | ||
|
|
a0fb9bc4ab | ||
|
|
a7bb6f6a95 | ||
|
|
f1bef73757 | ||
|
|
4598dd1a0f | ||
|
|
0241d6f443 | ||
|
|
72acb5509a | ||
|
|
b43e99fa20 | ||
|
|
b5083ddb1b | ||
|
|
f62e8b7be9 | ||
|
|
f655dc0dbc | ||
|
|
95e0fa2672 | ||
|
|
4b7c8f757e | ||
|
|
381e9c744a | ||
|
|
9aa4bb3f4b | ||
|
|
63617edfef | ||
|
|
72de0450e0 | ||
|
|
306bdd322f | ||
|
|
231613cb3b | ||
|
|
2af5739592 | ||
|
|
b38f7c8f2a | ||
|
|
e3a81c1bed | ||
|
|
cd8452d839 | ||
|
|
4b38cb4c2e | ||
|
|
eda8c6f263 | ||
|
|
a8cf67c94d | ||
|
|
928b341fb3 | ||
|
|
6e51b1d50c | ||
|
|
78aaa65b45 | ||
|
|
3627d8f1ae | ||
|
|
1e64b817f6 | ||
|
|
37e999652d | ||
|
|
9408557f03 | ||
|
|
16701249b4 | ||
|
|
3c1ac134f2 | ||
|
|
230d9d993e | ||
|
|
d1c83ffc09 | ||
|
|
a52f991543 | ||
|
|
dfc56a3272 | ||
|
|
41037ce599 | ||
|
|
a3924ed40a | ||
|
|
361bd4e5f6 | ||
|
|
8cc245ac11 | ||
|
|
2d8a6e84c1 | ||
|
|
d2add54cd6 | ||
|
|
40044ac5a6 | ||
|
|
bb15d0636e | ||
|
|
2cc7d8395b | ||
|
|
2f2e039356 | ||
|
|
0cd388ca66 | ||
|
|
7ef1fe81f6 | ||
|
|
774610de7b | ||
|
|
f6c85e17d5 | ||
|
|
8142306562 | ||
|
|
2d84245103 | ||
|
|
1d954b192c | ||
|
|
db0604f585 | ||
|
|
08beb5fbe6 | ||
|
|
7df06b87a5 | ||
|
|
abf4e82737 | ||
|
|
7f8617d639 | ||
|
|
f5c62a82ac | ||
|
|
66514ec607 | ||
|
|
096e682b18 | ||
|
|
e098b3c504 | ||
|
|
4dde466364 | ||
|
|
6d6fc52481 | ||
|
|
eaae4af832 | ||
|
|
7f5afddb38 | ||
|
|
36a981aaa2 | ||
|
|
fdcf093be0 | ||
|
|
1bd55b4572 | ||
|
|
eb0e5b7438 | ||
|
|
884dece54c | ||
|
|
3759f4c644 | ||
|
|
f232f74246 | ||
|
|
a9ecab35d8 | ||
|
|
e1e25d0eae | ||
|
|
f45c042351 | ||
|
|
f15bb9dbd7 | ||
|
|
610871c61b | ||
|
|
35b9e4768a | ||
|
|
1b4762715c | ||
|
|
5c21538553 | ||
|
|
85481d7321 | ||
|
|
153fa16bcf | ||
|
|
71642f494f | ||
|
|
8ba408385b | ||
|
|
d2c420a1fd | ||
|
|
855ff480a5 | ||
|
|
eb586aab55 | ||
|
|
b097f30f4d | ||
|
|
78f565c706 | ||
|
|
af30d8b7cd | ||
|
|
e79a918c03 | ||
|
|
83dc92c6a5 | ||
|
|
64c80c32f0 | ||
|
|
12eba33dbf | ||
|
|
0eee1f2d01 | ||
|
|
39a5921522 | ||
|
|
c99a689504 | ||
|
|
997a3e18a3 | ||
|
|
15747f48e9 | ||
|
|
d62b46f6cd | ||
|
|
d406e4c3d9 | ||
|
|
fc7d37def4 | ||
|
|
f6b3dfe5ba | ||
|
|
fe9094dedc | ||
|
|
34ff5d9662 | ||
|
|
df9bad75ea | ||
|
|
21af3bf563 | ||
|
|
b2f5f095fc | ||
|
|
8a1ac566c8 | ||
|
|
75bf595f86 | ||
|
|
312f13e254 | ||
|
|
2fc4006dfa | ||
|
|
47f7ec16c0 | ||
|
|
e105616b96 | ||
|
|
a503134533 | ||
|
|
bceb8540a1 | ||
|
|
10c6a70696 | ||
|
|
b809d76b79 | ||
|
|
bfad85223b | ||
|
|
b53c5593a8 | ||
|
|
3bfb98a1c6 | ||
|
|
573fde4bbc | ||
|
|
5c8a076790 | ||
|
|
20b173453d | ||
|
|
3460c9f714 | ||
|
|
69a5bf0159 | ||
|
|
01f0f309d1 | ||
|
|
3d67e1dbdb | ||
|
|
719e21ac8c | ||
|
|
14ed3b82a0 | ||
|
|
9e5e43fcd5 | ||
|
|
7493b7f35e | ||
|
|
54b3a57f46 | ||
|
|
4f998a6880 | ||
|
|
62a6cdc9f7 | ||
|
|
bc83dfa9e2 | ||
|
|
5adbab1d2b | ||
|
|
b0c1a7acce | ||
|
|
14cadbf80d | ||
|
|
741ab3e45c | ||
|
|
f456dba993 | ||
|
|
50a21fbd74 | ||
|
|
768ae584d3 | ||
|
|
ae32315bf7 | ||
|
|
9821e05386 | ||
|
|
38bc3d47ad | ||
|
|
4feb3bf411 | ||
|
|
b53d6c370b | ||
|
|
31c550d410 |
98
.github/workflows/build.yml
vendored
Normal file
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.79/sni-v0.0.79-windows-amd64.zip -OutFile sni.zip
|
||||
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
|
||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/6.4/win-x64.zip -OutFile enemizer.zip
|
||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||
- name: Build
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools==60.10.0 # 61 does not work with the current layout
|
||||
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/black-sliver/sni/releases/download/v0.0.78-2/sni-v0.0.78-2-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/6.4/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==60.10.0 # setuptools same as windows
|
||||
"${{ 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
|
||||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -12,10 +12,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.8
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: 3.9
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
84
.github/workflows/release.yml
vendored
Normal file
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-latest
|
||||
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/black-sliver/sni/releases/download/v0.0.78-2/sni-v0.0.78-2-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/6.4/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
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.8
|
||||
- name: Set up Python ${{ matrix.python.version }}
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: ${{ matrix.python.version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8 pytest
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||
python ModuleUpdate.py --yes --force
|
||||
- name: Unittests
|
||||
run: |
|
||||
pytest test
|
||||
|
||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -4,9 +4,15 @@
|
||||
*_Spoiler.txt
|
||||
*.bmbp
|
||||
*.apbp
|
||||
*.apm3
|
||||
*.apmc
|
||||
*.apz5
|
||||
*.pyc
|
||||
*.pyd
|
||||
*.sfc
|
||||
*.z64
|
||||
*.n64
|
||||
*.nes
|
||||
*.wixobj
|
||||
*.lck
|
||||
*.db3
|
||||
@@ -16,20 +22,14 @@
|
||||
*.apsave
|
||||
|
||||
build
|
||||
/build_factorio/
|
||||
bundle/components.wxs
|
||||
dist
|
||||
README.html
|
||||
.vs/
|
||||
EnemizerCLI/
|
||||
RaceRom.py
|
||||
weights/
|
||||
/MultiMystery/
|
||||
/Players/
|
||||
/QUsb2Snes/
|
||||
/options.yaml
|
||||
/config.yaml
|
||||
/uploads/
|
||||
/logs/
|
||||
_persistent_storage.yaml
|
||||
mystery_result_*.yaml
|
||||
@@ -38,6 +38,12 @@ success.txt
|
||||
output/
|
||||
Output Logs/
|
||||
/factorio/
|
||||
/Minecraft Forge Server/
|
||||
/WebHostLib/static/generated
|
||||
/freeze_requirements.txt
|
||||
/Archipelago.zip
|
||||
/setup.ini
|
||||
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -145,4 +151,9 @@ dmypy.json
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
Archipelago.zip
|
||||
#minecraft server stuff
|
||||
jdk*/
|
||||
minecraft*/
|
||||
|
||||
#pyenv
|
||||
.python-version
|
||||
|
||||
1124
BaseClasses.py
1124
BaseClasses.py
File diff suppressed because it is too large
Load Diff
660
ChecksFinderClient.py
Normal file
660
ChecksFinderClient.py
Normal file
@@ -0,0 +1,660 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import urllib.parse
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
|
||||
import websockets
|
||||
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("ChecksFinderClient", exception_logger="Client")
|
||||
|
||||
from MultiServer import CommandProcessor
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
|
||||
from Utils import Version, stream_input
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
from CommonClient import gui_enabled, console_loop, logger, server_autoreconnect, get_base_parser, \
|
||||
keep_alive
|
||||
from worlds.checksfinder import ChecksFinderWorld
|
||||
|
||||
|
||||
class ClientCommandProcessor(CommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
self.ctx = ctx
|
||||
|
||||
def output(self, text: str):
|
||||
logger.info(text)
|
||||
|
||||
def _cmd_exit(self) -> bool:
|
||||
"""Close connections and client"""
|
||||
self.ctx.exit_event.set()
|
||||
return True
|
||||
|
||||
def _cmd_connect(self, address: str = "") -> bool:
|
||||
"""Connect to a MultiWorld Server"""
|
||||
self.ctx.server_address = 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
|
||||
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
||||
return True
|
||||
|
||||
def _cmd_received(self) -> bool:
|
||||
"""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]}")
|
||||
return True
|
||||
|
||||
def _cmd_missing(self) -> bool:
|
||||
"""List all missing location checks, from your local game state"""
|
||||
if not self.ctx.game:
|
||||
self.output("No game set, cannot determine missing checks.")
|
||||
return False
|
||||
count = 0
|
||||
checked_count = 0
|
||||
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
||||
if location_id < 0:
|
||||
continue
|
||||
if location_id not in self.ctx.locations_checked:
|
||||
if location_id in self.ctx.missing_locations:
|
||||
self.output('Missing: ' + location)
|
||||
count += 1
|
||||
elif location_id in self.ctx.checked_locations:
|
||||
self.output('Checked: ' + location)
|
||||
count += 1
|
||||
checked_count += 1
|
||||
|
||||
if count:
|
||||
self.output(
|
||||
f"Found {count} missing location checks{f'. {checked_count} location checks previously visited.' if checked_count else ''}")
|
||||
else:
|
||||
self.output("No missing location checks found.")
|
||||
return True
|
||||
|
||||
def _cmd_items(self):
|
||||
"""List all item names for the currently running game."""
|
||||
self.output(f"Item Names for {self.ctx.game}")
|
||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
||||
self.output(item_name)
|
||||
|
||||
def _cmd_locations(self):
|
||||
"""List all location names for the currently running game."""
|
||||
self.output(f"Location Names for {self.ctx.game}")
|
||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
||||
self.output(location_name)
|
||||
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.output(f"Syncing items.")
|
||||
self.ctx.syncing = True
|
||||
|
||||
def _cmd_ready(self):
|
||||
"""Send ready status to server."""
|
||||
self.ctx.ready = not self.ctx.ready
|
||||
if self.ctx.ready:
|
||||
state = ClientStatus.CLIENT_READY
|
||||
self.output("Readied up.")
|
||||
else:
|
||||
state = ClientStatus.CLIENT_CONNECTED
|
||||
self.output("Unreadied.")
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||
|
||||
def default(self, raw: str):
|
||||
raw = self.ctx.on_user_say(raw)
|
||||
if raw:
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||
|
||||
|
||||
class CommonContext():
|
||||
tags: typing.Set[str] = {"AP"}
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: int = ClientCommandProcessor
|
||||
game = None
|
||||
ui = None
|
||||
keep_alive_task = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
current_energy_link_value = 0 # to display in UI, gets set by server
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
# server state
|
||||
self.send_index: int = 0
|
||||
self.server_address = server_address
|
||||
self.password = password
|
||||
self.syncing = False
|
||||
self.awaiting_bridge = False
|
||||
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.permissions = {
|
||||
"forfeit": "disabled",
|
||||
"collect": "disabled",
|
||||
"remaining": "disabled",
|
||||
}
|
||||
|
||||
# own state
|
||||
self.finished_game = False
|
||||
self.ready = False
|
||||
self.team = None
|
||||
self.slot = None
|
||||
self.auth = None
|
||||
self.seed_name = None
|
||||
|
||||
self.locations_checked: typing.Set[int] = set() # local state
|
||||
self.locations_scouted: typing.Set[int] = set()
|
||||
self.items_received = []
|
||||
self.missing_locations: typing.Set[int] = set()
|
||||
self.checked_locations: typing.Set[int] = 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.exit_event = asyncio.Event()
|
||||
self.watcher_event = asyncio.Event()
|
||||
|
||||
self.slow_mode = False
|
||||
self.jsontotextparser = JSONtoTextParser(self)
|
||||
self.set_getters(network_data_package)
|
||||
|
||||
# execution
|
||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||
|
||||
@property
|
||||
def total_locations(self) -> typing.Optional[int]:
|
||||
"""Will return None until connected."""
|
||||
if self.checked_locations or self.missing_locations:
|
||||
return len(self.checked_locations | self.missing_locations)
|
||||
|
||||
async def connection_closed(self):
|
||||
self.auth = 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
|
||||
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root+"/"+file)
|
||||
|
||||
# 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 []
|
||||
|
||||
async def disconnect(self):
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
if self.server_task is not None:
|
||||
await self.server_task
|
||||
|
||||
async def send_msgs(self, msgs):
|
||||
if not self.server or not self.server.socket.open or self.server.socket.closed:
|
||||
return
|
||||
await self.server.socket.send(encode(msgs))
|
||||
|
||||
def consume_players_package(self, package: typing.List[tuple]):
|
||||
self.player_names = {slot: name for team, slot, name, orig_name in package if self.team == team}
|
||||
self.player_names[0] = "Archipelago"
|
||||
|
||||
def event_invalid_slot(self):
|
||||
raise Exception('Invalid Slot; please verify that you have connected to the correct world.')
|
||||
|
||||
def event_invalid_game(self):
|
||||
raise Exception('Invalid Game; please verify that you connected with the right game to the correct world.')
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
logger.info('Enter the password required to join this game:')
|
||||
self.password = await self.console_input()
|
||||
return self.password
|
||||
|
||||
async def send_connect(self, **kwargs):
|
||||
payload = {
|
||||
'cmd': 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
'tags': self.tags, 'items_handling': self.items_handling,
|
||||
'uuid': Utils.get_unique_identifier(), 'game': self.game
|
||||
}
|
||||
if kwargs:
|
||||
payload.update(kwargs)
|
||||
await self.send_msgs([payload])
|
||||
|
||||
async def console_input(self):
|
||||
self.input_requests += 1
|
||||
return await self.input_queue.get()
|
||||
|
||||
async def connect(self, address=None):
|
||||
await self.disconnect()
|
||||
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
||||
|
||||
def on_print(self, args: dict):
|
||||
logger.info(args["text"])
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if self.ui:
|
||||
self.ui.print_json(args["data"])
|
||||
else:
|
||||
text = self.jsontotextparser(args["data"])
|
||||
logger.info(text)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
pass
|
||||
|
||||
def on_user_say(self, text: str) -> typing.Optional[str]:
|
||||
"""Gets called before sending a Say to the server from the user.
|
||||
Returned text is sent, or sending is aborted if None is returned."""
|
||||
return text
|
||||
|
||||
def update_permissions(self, permissions: typing.Dict[str, int]):
|
||||
for permission_name, permission_flag in permissions.items():
|
||||
try:
|
||||
flag = Permission(permission_flag)
|
||||
logger.info(f"{permission_name.capitalize()} permission: {flag.name}")
|
||||
self.permissions[permission_name] = flag.name
|
||||
except Exception as e: # safeguard against permissions that may be implemented in the future
|
||||
logger.exception(e)
|
||||
|
||||
async def shutdown(self):
|
||||
self.server_address = None
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
if self.server_task:
|
||||
await self.server_task
|
||||
|
||||
while self.input_requests > 0:
|
||||
self.input_queue.put_nowait(None)
|
||||
self.input_requests -= 1
|
||||
self.keep_alive_task.cancel()
|
||||
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root+"/"+file)
|
||||
|
||||
# DeathLink hooks
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
||||
self.last_death_link = max(data["time"], self.last_death_link)
|
||||
text = data.get("cause", "")
|
||||
if text:
|
||||
logger.info(f"DeathLink: {text}")
|
||||
else:
|
||||
logger.info(f"DeathLink: Received from {data['source']}")
|
||||
|
||||
async def send_death(self, death_text: str = ""):
|
||||
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):
|
||||
old_tags = self.tags.copy()
|
||||
if death_link:
|
||||
self.tags.add("DeathLink")
|
||||
else:
|
||||
self.tags -= {"DeathLink"}
|
||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||
|
||||
|
||||
async def server_loop(ctx: CommonContext, address=None):
|
||||
cached_address = None
|
||||
if ctx.server and ctx.server.socket:
|
||||
logger.error('Already connected')
|
||||
return
|
||||
|
||||
if address is None: # set through CLI or APBP
|
||||
address = ctx.server_address
|
||||
|
||||
# Wait for the user to provide a multiworld server address
|
||||
if not address:
|
||||
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
|
||||
logger.info(f'Connecting to Archipelago server at {address}')
|
||||
try:
|
||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
||||
ctx.server = Endpoint(socket)
|
||||
logger.info('Connected')
|
||||
ctx.server_address = address
|
||||
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
|
||||
async for data in ctx.server.socket:
|
||||
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 Exception as e:
|
||||
logger.exception('Lost connection to the multiworld server, type /connect to reconnect')
|
||||
finally:
|
||||
await ctx.connection_closed()
|
||||
if ctx.server_address:
|
||||
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
|
||||
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
||||
ctx.current_reconnect_delay *= 2
|
||||
|
||||
|
||||
async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
try:
|
||||
cmd = args["cmd"]
|
||||
except:
|
||||
logger.exception(f"Could not get command from {args}")
|
||||
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)")
|
||||
else:
|
||||
logger.info('--------------------------------')
|
||||
logger.info('Room Information:')
|
||||
logger.info('--------------------------------')
|
||||
version = args["version"]
|
||||
ctx.server_version = tuple(version)
|
||||
version = ".".join(str(item) for item in version)
|
||||
|
||||
logger.info(f'Server protocol version: {version}')
|
||||
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
|
||||
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:
|
||||
logger.info('No player connected')
|
||||
else:
|
||||
args['players'].sort()
|
||||
current_team = -1
|
||||
logger.info('Connected Players:')
|
||||
for network_player in args['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"}])
|
||||
await ctx.server_auth(args['password'])
|
||||
|
||||
elif cmd == 'DataPackage':
|
||||
logger.info("Got new ID/Name Datapackage")
|
||||
ctx.set_getters(args['data'], network=True)
|
||||
|
||||
elif cmd == 'ConnectionRefused':
|
||||
errors = args["errors"]
|
||||
if 'InvalidSlot' in errors:
|
||||
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')
|
||||
ctx.password = None
|
||||
await ctx.server_auth(True)
|
||||
elif errors:
|
||||
raise Exception("Unknown connection errors: " + str(errors))
|
||||
else:
|
||||
raise Exception('Connection refused by the multiworld host, no reason provided')
|
||||
|
||||
elif cmd == 'Connected':
|
||||
if not os.path.exists(os.path.expandvars(r"%localappdata%/ChecksFinder")):
|
||||
os.mkdir(os.path.expandvars(r"%localappdata%/ChecksFinder"))
|
||||
ctx.team = args["team"]
|
||||
ctx.slot = args["slot"]
|
||||
ctx.consume_players_package(args["players"])
|
||||
msgs = []
|
||||
if ctx.locations_checked:
|
||||
msgs.append({"cmd": "LocationChecks",
|
||||
"locations": list(ctx.locations_checked)})
|
||||
if ctx.locations_scouted:
|
||||
msgs.append({"cmd": "LocationScouts",
|
||||
"locations": list(ctx.locations_scouted)})
|
||||
if msgs:
|
||||
await ctx.send_msgs(msgs)
|
||||
if ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
|
||||
# Get the server side view of missing as of time of connecting.
|
||||
# This list is used to only send to the server what is reported as ACTUALLY Missing.
|
||||
# This also serves to allow an easy visual of what locations were already checked previously
|
||||
# when /missing is used for the client side view of what is missing.
|
||||
ctx.missing_locations = set(args["missing_locations"])
|
||||
ctx.checked_locations = set(args["checked_locations"])
|
||||
for ss in ctx.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
|
||||
f.close()
|
||||
|
||||
elif cmd == 'ReceivedItems':
|
||||
start_index = args["index"]
|
||||
|
||||
if start_index == 0:
|
||||
ctx.items_received = []
|
||||
elif start_index != len(ctx.items_received):
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks",
|
||||
"locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
if start_index == len(ctx.items_received):
|
||||
for item in args['items']:
|
||||
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
|
||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
|
||||
f.write(str(NetworkItem(*item).item))
|
||||
f.close()
|
||||
ctx.items_received.append(NetworkItem(*item))
|
||||
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)
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == "RoomUpdate":
|
||||
if "players" in args:
|
||||
ctx.consume_players_package(args["players"])
|
||||
if "hint_points" in args:
|
||||
ctx.hint_points = args['hint_points']
|
||||
if "checked_locations" in args:
|
||||
checked = set(args["checked_locations"])
|
||||
ctx.checked_locations |= checked
|
||||
ctx.missing_locations -= checked
|
||||
for ss in ctx.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
|
||||
f.close()
|
||||
if "permissions" in args:
|
||||
ctx.update_permissions(args["permissions"])
|
||||
|
||||
elif cmd == 'Print':
|
||||
ctx.on_print(args)
|
||||
|
||||
elif cmd == 'PrintJSON':
|
||||
ctx.on_print_json(args)
|
||||
|
||||
elif cmd == 'InvalidPacket':
|
||||
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
|
||||
|
||||
elif cmd == "Bounced":
|
||||
tags = args.get("tags", [])
|
||||
# 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}")
|
||||
|
||||
ctx.on_package(cmd, args)
|
||||
|
||||
|
||||
async def game_watcher(ctx: CommonContext):
|
||||
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
|
||||
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
||||
sending = []
|
||||
victory = False
|
||||
for root, dirs, files in os.walk(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__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
|
||||
class TextContext(CommonContext):
|
||||
game = "ChecksFinder"
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
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.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.games.get(self.slot, None)
|
||||
|
||||
|
||||
async def main(args):
|
||||
ctx = TextContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
input_task = None
|
||||
if gui_enabled:
|
||||
from kvui import ChecksFinderManager
|
||||
ctx.ui = ChecksFinderManager(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")
|
||||
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()
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="ChecksFinder Client, for text interfacing.")
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
colorama.deinit()
|
||||
350
CommonClient.py
350
CommonClient.py
@@ -1,24 +1,29 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import typing
|
||||
import asyncio
|
||||
import urllib.parse
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
|
||||
import websockets
|
||||
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("TextClient", exception_logger="Client")
|
||||
|
||||
from MultiServer import CommandProcessor
|
||||
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, color, ClientStatus
|
||||
from Utils import Version
|
||||
|
||||
# logging note:
|
||||
# logging.* gets send to only the text console, logger.* gets send to the WebUI as well, if it's initialized.
|
||||
from worlds import network_data_package
|
||||
from worlds.alttp import Items, Regions
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
|
||||
from Utils import Version, stream_input
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
# without terminal we have to use gui mode
|
||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
||||
|
||||
|
||||
class ClientCommandProcessor(CommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
self.ctx = ctx
|
||||
@@ -34,13 +39,13 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
def _cmd_connect(self, address: str = "") -> bool:
|
||||
"""Connect to a MultiWorld Server"""
|
||||
self.ctx.server_address = None
|
||||
asyncio.create_task(self.ctx.connect(address if address else 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
|
||||
asyncio.create_task(self.ctx.disconnect())
|
||||
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
||||
return True
|
||||
|
||||
def _cmd_received(self) -> bool:
|
||||
@@ -52,9 +57,12 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
|
||||
def _cmd_missing(self) -> bool:
|
||||
"""List all missing location checks, from your local game state"""
|
||||
if not self.ctx.game:
|
||||
self.output("No game set, cannot determine missing checks.")
|
||||
return False
|
||||
count = 0
|
||||
checked_count = 0
|
||||
for location, location_id in Regions.lookup_name_to_id.items():
|
||||
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
||||
if location_id < 0:
|
||||
continue
|
||||
if location_id not in self.ctx.locations_checked:
|
||||
@@ -73,9 +81,20 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
self.output("No missing location checks found.")
|
||||
return True
|
||||
|
||||
def _cmd_items(self):
|
||||
"""List all item names for the currently running game."""
|
||||
self.output(f"Item Names for {self.ctx.game}")
|
||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
||||
self.output(item_name)
|
||||
|
||||
def _cmd_locations(self):
|
||||
"""List all location names for the currently running game."""
|
||||
self.output(f"Location Names for {self.ctx.game}")
|
||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
||||
self.output(location_name)
|
||||
|
||||
def _cmd_ready(self):
|
||||
"""Send ready status to server."""
|
||||
self.ctx.ready = not self.ctx.ready
|
||||
if self.ctx.ready:
|
||||
state = ClientStatus.CLIENT_READY
|
||||
@@ -83,42 +102,60 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
else:
|
||||
state = ClientStatus.CLIENT_CONNECTED
|
||||
self.output("Unreadied.")
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]))
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||
|
||||
def default(self, raw: str):
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]))
|
||||
raw = self.ctx.on_user_say(raw)
|
||||
if raw:
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||
|
||||
|
||||
class CommonContext():
|
||||
starting_reconnect_delay = 5
|
||||
current_reconnect_delay = starting_reconnect_delay
|
||||
command_processor = ClientCommandProcessor
|
||||
def __init__(self, server_address, password, found_items: bool):
|
||||
tags: typing.Set[str] = {"AP"}
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: int = ClientCommandProcessor
|
||||
game = None
|
||||
ui = None
|
||||
keep_alive_task = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
current_energy_link_value = 0 # to display in UI, gets set by server
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
# server state
|
||||
self.server_address = server_address
|
||||
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.permissions = {
|
||||
"forfeit": "disabled",
|
||||
"collect": "disabled",
|
||||
"remaining": "disabled",
|
||||
}
|
||||
|
||||
# own state
|
||||
self.finished_game = False
|
||||
self.ready = False
|
||||
self.found_items = found_items
|
||||
self.team = None
|
||||
self.slot = None
|
||||
self.auth = None
|
||||
self.seed_name = None
|
||||
|
||||
self.locations_checked: typing.Set[int] = set()
|
||||
self.locations_checked: typing.Set[int] = set() # local state
|
||||
self.locations_scouted: typing.Set[int] = set()
|
||||
self.items_received = []
|
||||
self.missing_locations: typing.List[int] = []
|
||||
self.checked_locations: typing.List[int] = []
|
||||
self.locations_info = {}
|
||||
self.missing_locations: typing.Set[int] = set()
|
||||
self.checked_locations: typing.Set[int] = set() # server state
|
||||
self.locations_info: typing.Dict[int, NetworkItem] = {}
|
||||
|
||||
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.exit_event = asyncio.Event()
|
||||
@@ -128,6 +165,15 @@ class CommonContext():
|
||||
self.jsontotextparser = JSONtoTextParser(self)
|
||||
self.set_getters(network_data_package)
|
||||
|
||||
# execution
|
||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||
|
||||
@property
|
||||
def total_locations(self) -> typing.Optional[int]:
|
||||
"""Will return None until connected."""
|
||||
if self.checked_locations or self.missing_locations:
|
||||
return len(self.checked_locations | self.missing_locations)
|
||||
|
||||
async def connection_closed(self):
|
||||
self.auth = None
|
||||
self.items_received = []
|
||||
@@ -138,22 +184,24 @@ class CommonContext():
|
||||
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
|
||||
for key, value in data_package.items():
|
||||
if type(value) == dict: # convert to int keys
|
||||
data_package[key] = \
|
||||
{int(subkey) if subkey.isdigit() else subkey: subvalue for subkey, subvalue in value.items()}
|
||||
|
||||
if data_package["version"] > network_data_package["version"]:
|
||||
Utils.persistent_store("datapackage", "latest", network_data_package)
|
||||
|
||||
item_lookup: dict = data_package["lookup_any_item_id_to_name"]
|
||||
locations_lookup: dict = data_package["lookup_any_location_id_to_name"]
|
||||
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})')
|
||||
@@ -190,25 +238,119 @@ class CommonContext():
|
||||
def event_invalid_slot(self):
|
||||
raise Exception('Invalid Slot; please verify that you have connected to the correct world.')
|
||||
|
||||
async def server_auth(self, password_requested):
|
||||
def event_invalid_game(self):
|
||||
raise Exception('Invalid Game; please verify that you connected with the right game to the correct world.')
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
logger.info('Enter the password required to join this game:')
|
||||
self.password = await self.console_input()
|
||||
return self.password
|
||||
|
||||
async def send_connect(self, **kwargs):
|
||||
payload = {
|
||||
'cmd': 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
'tags': self.tags, 'items_handling': self.items_handling,
|
||||
'uuid': Utils.get_unique_identifier(), 'game': self.game
|
||||
}
|
||||
if kwargs:
|
||||
payload.update(kwargs)
|
||||
await self.send_msgs([payload])
|
||||
|
||||
async def console_input(self):
|
||||
self.input_requests += 1
|
||||
return await self.input_queue.get()
|
||||
|
||||
async def connect(self, address= None):
|
||||
async def connect(self, address=None):
|
||||
await self.disconnect()
|
||||
self.server_task = asyncio.create_task(server_loop(self, address))
|
||||
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
||||
|
||||
def on_print(self, args: dict):
|
||||
logger.info(args["text"])
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
logger.info(self.jsontotextparser(args["data"]))
|
||||
if self.ui:
|
||||
self.ui.print_json(args["data"])
|
||||
else:
|
||||
text = self.jsontotextparser(args["data"])
|
||||
logger.info(text)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
"""For custom package handling in subclasses."""
|
||||
pass
|
||||
|
||||
def on_user_say(self, text: str) -> typing.Optional[str]:
|
||||
"""Gets called before sending a Say to the server from the user.
|
||||
Returned text is sent, or sending is aborted if None is returned."""
|
||||
return text
|
||||
|
||||
def update_permissions(self, permissions: typing.Dict[str, int]):
|
||||
for permission_name, permission_flag in permissions.items():
|
||||
try:
|
||||
flag = Permission(permission_flag)
|
||||
logger.info(f"{permission_name.capitalize()} permission: {flag.name}")
|
||||
self.permissions[permission_name] = flag.name
|
||||
except Exception as e: # safeguard against permissions that may be implemented in the future
|
||||
logger.exception(e)
|
||||
|
||||
async def shutdown(self):
|
||||
self.server_address = None
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
if self.server_task:
|
||||
await self.server_task
|
||||
|
||||
while self.input_requests > 0:
|
||||
self.input_queue.put_nowait(None)
|
||||
self.input_requests -= 1
|
||||
self.keep_alive_task.cancel()
|
||||
|
||||
# DeathLink hooks
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
||||
self.last_death_link = max(data["time"], self.last_death_link)
|
||||
text = data.get("cause", "")
|
||||
if text:
|
||||
logger.info(f"DeathLink: {text}")
|
||||
else:
|
||||
logger.info(f"DeathLink: Received from {data['source']}")
|
||||
|
||||
async def send_death(self, death_text: str = ""):
|
||||
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):
|
||||
old_tags = self.tags.copy()
|
||||
if death_link:
|
||||
self.tags.add("DeathLink")
|
||||
else:
|
||||
self.tags -= {"DeathLink"}
|
||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||
|
||||
|
||||
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)
|
||||
so we send a payload to prevent drop and if we were dropped anyway this will cause an auto-reconnect."""
|
||||
seconds_elapsed = 0
|
||||
while not ctx.exit_event.is_set():
|
||||
await asyncio.sleep(1) # short sleep to not block program shutdown
|
||||
if ctx.server and ctx.slot:
|
||||
seconds_elapsed += 1
|
||||
if seconds_elapsed > seconds_between_checks:
|
||||
await ctx.send_msgs([{"cmd": "Bounce", "slots": [ctx.slot]}])
|
||||
seconds_elapsed = 0
|
||||
|
||||
|
||||
async def server_loop(ctx: CommonContext, address=None):
|
||||
@@ -244,25 +386,25 @@ async def server_loop(ctx: CommonContext, address=None):
|
||||
logger.error('Unable to connect to multiworld server at cached address. '
|
||||
'Please use the connect button above.')
|
||||
else:
|
||||
logger.error('Connection refused by the multiworld server')
|
||||
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.error('Failed to connect to the multiworld server')
|
||||
logger.exception('Failed to connect to the multiworld server')
|
||||
except Exception as e:
|
||||
logger.error('Lost connection to the multiworld server, type /connect to reconnect')
|
||||
if not isinstance(e, websockets.WebSocketException):
|
||||
logger.exception(e)
|
||||
logger.exception('Lost connection to the multiworld server, type /connect to reconnect')
|
||||
finally:
|
||||
await ctx.connection_closed()
|
||||
if ctx.server_address:
|
||||
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
|
||||
asyncio.create_task(server_autoreconnect(ctx))
|
||||
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
||||
ctx.current_reconnect_delay *= 2
|
||||
|
||||
|
||||
async def server_autoreconnect(ctx: CommonContext):
|
||||
await asyncio.sleep(ctx.current_reconnect_delay)
|
||||
if ctx.server_address and ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx))
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
|
||||
|
||||
async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
@@ -286,20 +428,22 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
|
||||
if args['password']:
|
||||
logger.info('Password required')
|
||||
logger.info(f"Forfeit setting: {args['forfeit_mode']}")
|
||||
logger.info(f"Remaining setting: {args['remaining_mode']}")
|
||||
logger.info(f"A !hint costs {args['hint_cost']}% of checks points and you get {args['location_check_points']}"
|
||||
f" for each location checked. Use !hint for more information.")
|
||||
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'])
|
||||
ctx.forfeit_mode = args['forfeit_mode']
|
||||
ctx.remaining_mode = args['remaining_mode']
|
||||
|
||||
if len(args['players']) < 1:
|
||||
logger.info('No player connected')
|
||||
else:
|
||||
args['players'].sort()
|
||||
current_team = -1
|
||||
logger.info('Players:')
|
||||
logger.info('Connected Players:')
|
||||
for network_player in args['players']:
|
||||
if network_player.team != current_team:
|
||||
logger.info(f' Team #{network_player.team + 1}')
|
||||
@@ -317,11 +461,14 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
errors = args["errors"]
|
||||
if 'InvalidSlot' in errors:
|
||||
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')
|
||||
@@ -352,8 +499,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
# This list is used to only send to the server what is reported as ACTUALLY Missing.
|
||||
# This also serves to allow an easy visual of what locations were already checked previously
|
||||
# when /missing is used for the client side view of what is missing.
|
||||
ctx.missing_locations = args["missing_locations"]
|
||||
ctx.checked_locations = args["checked_locations"]
|
||||
ctx.missing_locations = set(args["missing_locations"])
|
||||
ctx.checked_locations = set(args["checked_locations"])
|
||||
|
||||
elif cmd == 'ReceivedItems':
|
||||
start_index = args["index"]
|
||||
@@ -372,9 +519,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":
|
||||
@@ -382,6 +528,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.consume_players_package(args["players"])
|
||||
if "hint_points" in args:
|
||||
ctx.hint_points = args['hint_points']
|
||||
if "checked_locations" in args:
|
||||
checked = set(args["checked_locations"])
|
||||
ctx.checked_locations |= checked
|
||||
ctx.missing_locations -= checked
|
||||
if "permissions" in args:
|
||||
ctx.update_permissions(args["permissions"])
|
||||
|
||||
elif cmd == 'Print':
|
||||
ctx.on_print(args)
|
||||
@@ -389,22 +541,34 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
elif cmd == 'PrintJSON':
|
||||
ctx.on_print_json(args)
|
||||
|
||||
elif cmd == 'InvalidArguments':
|
||||
logger.warning(f"Invalid Arguments: {args['text']}")
|
||||
elif cmd == 'InvalidPacket':
|
||||
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
|
||||
|
||||
elif cmd == "Bounced":
|
||||
tags = args.get("tags", [])
|
||||
# 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}")
|
||||
|
||||
ctx.on_package(cmd, args)
|
||||
|
||||
|
||||
async def console_loop(ctx: CommonContext):
|
||||
import sys
|
||||
commandprocessor = ctx.command_processor(ctx)
|
||||
queue = asyncio.Queue()
|
||||
stream_input(sys.stdin, queue)
|
||||
while not ctx.exit_event.is_set():
|
||||
try:
|
||||
input_text = await asyncio.get_event_loop().run_in_executor(
|
||||
None, sys.stdin.readline
|
||||
)
|
||||
input_text = input_text.strip()
|
||||
input_text = await queue.get()
|
||||
queue.task_done()
|
||||
|
||||
if ctx.input_requests > 0:
|
||||
ctx.input_requests -= 1
|
||||
@@ -414,4 +578,70 @@ async def console_loop(ctx: CommonContext):
|
||||
if input_text:
|
||||
commandprocessor(input_text)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logger.exception(e)
|
||||
|
||||
|
||||
def get_base_parser(description=None):
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||
parser.add_argument('--password', default=None, help='Password of the multiworld host.')
|
||||
if sys.stdout: # If terminal output exists, offer gui-less mode
|
||||
parser.add_argument('--nogui', default=False, action='store_true', help="Turns off Client GUI.")
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
|
||||
class TextContext(CommonContext):
|
||||
tags = {"AP", "IgnoreGame", "TextOnly"}
|
||||
game = "Archipelago"
|
||||
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.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.games.get(self.slot, None)
|
||||
|
||||
|
||||
async def main(args):
|
||||
ctx = TextContext(args.connect, args.password)
|
||||
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")
|
||||
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.")
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
colorama.deinit()
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
Hint description:
|
||||
|
||||
Hints will appear in the following ratios across the 15 telepathic tiles that have hints and the five storyteller locations:
|
||||
|
||||
4 hints for inconvenient entrances.
|
||||
4 hints for random entrances (this can by coincidence pick inconvenient entrances that aren't used for the first set of hints).
|
||||
3 hints for inconvenient item locations.
|
||||
5 hints for valuable items.
|
||||
4 junk hints.
|
||||
|
||||
In the vanilla, dungeonssimple, and dungeonsfull shuffles, the following ratios will be used instead:
|
||||
|
||||
5 hints for inconvenient item locations.
|
||||
8 hints for valuable items.
|
||||
7 junk hints.
|
||||
|
||||
In the simple, restricted, and restricted legacy shuffles, these are the ratios:
|
||||
|
||||
2 hints for inconvenient entrances.
|
||||
1 hint for an inconvenient dungeon entrance.
|
||||
4 hints for random entrances (this can by coincidence pick inconvenient entrances that aren't used for the first set of hints).
|
||||
3 hints for inconvenient item locations.
|
||||
5 hints for valuable items.
|
||||
5 junk hints.
|
||||
|
||||
These hints will use the following format:
|
||||
|
||||
Entrance hints go "[Entrance on overworld] leads to [interior]".
|
||||
|
||||
Inconvenient item locations are a little more custom but amount to "[Location] has [item name]". The item name is literal and will specify which dungeon the dungeon specific items hail from (small key/big key/map/compass).
|
||||
|
||||
The valuable items are of the format "[item name] can be found [location]". The item name is again literal, and the location text is taken from Ganon's silver arrow hints. Note that the way it works is that every unique valuable item that exists is considered independently, and you won't get multiple hints for the EXACT same item (so you can only get one hint for Progressive Sword no matter how many swords exist in the seed, but if swords are not progressive, you could get hints for both Master Sword and Tempered Sword). More copies of an item existing does not increase the probability of getting a hint for that particular item (you are equally likely to get a hint for a Progressive Sword as for the Hammer). Unlike the IR, item names are never obfuscated by "something unique", and there is no special bias for hints for GT Big Key or Pegasus Boots.
|
||||
|
||||
Hint Locations:
|
||||
|
||||
Eastern Palace room before Big Chest
|
||||
Desert Palace bonk torch room
|
||||
Tower of Hera entrance room
|
||||
Tower of Hera Big Chest room
|
||||
Castle Tower after dark rooms
|
||||
Palace of Darkness before Bow section
|
||||
Swamp Palace entryway
|
||||
Thieves' Town upstairs
|
||||
Ice Palace entrance
|
||||
Ice Palace after first drop
|
||||
Ice Palace tall ice floor room
|
||||
Misery Mire cutscene room
|
||||
Turtle Rock entrance
|
||||
Spectacle Rock cave
|
||||
Spiky Hint cave
|
||||
PoD Bdlg NPC
|
||||
Near PoD Storyteller (bug near bomb wall)
|
||||
Dark Sanctuary Storyteller (long room with tables)
|
||||
Near Mire Storyteller (feather duster in winding cave)
|
||||
SE DW Storyteller (owl in winding cave)
|
||||
|
||||
Inconvenient entrance list:
|
||||
|
||||
Skull Woods Final
|
||||
Ice Palace
|
||||
Misery Mire
|
||||
Turtle Rock
|
||||
Ganon's Tower
|
||||
Mimic Ledge
|
||||
SW DM Foothills Cave (mirror from upper Bumper ledge)
|
||||
Hammer Pegs (near purple chest)
|
||||
Super Bomb cracked wall
|
||||
|
||||
Inconvenient location list:
|
||||
|
||||
Swamp left (two chests)
|
||||
Mire left (two chests)
|
||||
Hera basement
|
||||
Eastern Palace Big Key chest (protected by anti-fairies)
|
||||
Thieves' Town Big Chest
|
||||
Ice Palace Big Chest
|
||||
Ganon's Tower Big Chest
|
||||
Purple Chest
|
||||
Spike Cave
|
||||
Magic Bat
|
||||
Sahasrahla (Green Pendant)
|
||||
|
||||
In the vanilla, dungeonssimple, and dungeonsfull shuffles, the following two locations are added to the inconvenient locations list:
|
||||
|
||||
Graveyard Cave
|
||||
Mimic Cave
|
||||
|
||||
Valuable Items are simply all items that are shown on the pause subscreen (Y, B, or A sections) minus Silver Arrows and plus Triforce Pieces, Magic Upgrades (1/2 or 1/4), and the Single Arrow. If key shuffle is being used, you can additionally get hints for Small Keys or Big Keys but not hints for Maps or Compasses.
|
||||
|
||||
While the exact verbage of location names and item names can be found in the source code, here's a copy for reference:
|
||||
|
||||
Overworld Entrance naming:
|
||||
|
||||
Turtle Rock: Turtle Rock Main
|
||||
Misery Mire: Misery Mire
|
||||
Ice Palace: Ice Palace
|
||||
Skull Woods Final Section: The back of Skull Woods
|
||||
Death Mountain Return Cave (West): The SW DM Foothills Cave
|
||||
Mimic Cave: Mimic Ledge
|
||||
Dark World Hammer Peg Cave: The rows of pegs
|
||||
Pyramid Fairy: The crack on the pyramid
|
||||
Eastern Palace: Eastern Palace
|
||||
Elder House (East): Elder House
|
||||
Elder House (West): Elder House
|
||||
Two Brothers House (East): Eastern Quarreling Brothers' house
|
||||
Old Man Cave (West): The lower DM entrance
|
||||
Hyrule Castle Entrance (South): The ground level castle door
|
||||
Thieves Town: Thieves' Town
|
||||
Bumper Cave (Bottom): The lower Bumper Cave
|
||||
Swamp Palace: Swamp Palace
|
||||
Dark Death Mountain Ledge (West): The East dark DM connector ledge
|
||||
Dark Death Mountain Ledge (East): The East dark DM connector ledge
|
||||
Superbunny Cave (Top): The summit of dark DM cave
|
||||
Superbunny Cave (Bottom): The base of east dark DM
|
||||
Hookshot Cave: The rock on dark DM
|
||||
Desert Palace Entrance (South): The book sealed passage
|
||||
Tower of Hera: The Tower of Hera
|
||||
Two Brothers House (West): The door near the race game
|
||||
Old Man Cave (East): The SW-most cave on west DM
|
||||
Old Man House (Bottom): A cave with a door on west DM
|
||||
Old Man House (Top): The eastmost cave on west DM
|
||||
Death Mountain Return Cave (East): The westmost cave on west DM
|
||||
Spectacle Rock Cave Peak: The highest cave on west DM
|
||||
Spectacle Rock Cave: The right ledge on west DM
|
||||
Spectacle Rock Cave (Bottom): The left ledge on west DM
|
||||
Paradox Cave (Bottom): The right paired cave on east DM
|
||||
Paradox Cave (Middle): The southmost cave on east DM
|
||||
Paradox Cave (Top): The east DM summit cave
|
||||
Fairy Ascension Cave (Bottom): The east DM cave behind rocks
|
||||
Fairy Ascension Cave (Top): The central ledge on east DM
|
||||
Spiral Cave: The left ledge on east DM
|
||||
Spiral Cave (Bottom): The SWmost cave on east DM
|
||||
Palace of Darkness: Palace of Darkness
|
||||
Hyrule Castle Entrance (West): The left castle door
|
||||
Hyrule Castle Entrance (East): The right castle door
|
||||
Agahnims Tower: The sealed castle door
|
||||
Desert Palace Entrance (West): The westmost building in the desert
|
||||
Desert Palace Entrance (North): The northmost cave in the desert
|
||||
Blinds Hideout: Blind's old house
|
||||
Lake Hylia Fairy: A cave NE of Lake Hylia
|
||||
Light Hype Fairy: The cave south of your house
|
||||
Desert Fairy: The cave near the desert
|
||||
Chicken House: The chicken lady's house
|
||||
Aginahs Cave: The open desert cave
|
||||
Sahasrahlas Hut: The house near armos
|
||||
Cave Shop (Lake Hylia): The cave NW Lake Hylia
|
||||
Blacksmiths Hut: The old smithery
|
||||
Sick Kids House: The central house in Kakariko
|
||||
Lost Woods Gamble: A tree trunk door
|
||||
Fortune Teller (Light): A building NE of Kakariko
|
||||
Snitch Lady (East): A house guarded by a snitch
|
||||
Snitch Lady (West): A house guarded by a snitch
|
||||
Bush Covered House: A house with an uncut lawn
|
||||
Tavern (Front): A building with a backdoor
|
||||
Light World Bomb Hut: A Kakariko building with no door
|
||||
Kakariko Shop: The old Kakariko shop
|
||||
Mini Moldorm Cave: The cave south of Lake Hylia
|
||||
Long Fairy Cave: The eastmost portal cave
|
||||
Good Bee Cave: The open cave SE Lake Hylia
|
||||
20 Rupee Cave: The rock SE Lake Hylia
|
||||
50 Rupee Cave: The rock near the desert
|
||||
Ice Rod Cave: The sealed cave SE Lake Hylia
|
||||
Library: The old library
|
||||
Potion Shop: The witch's building
|
||||
Dam: The old dam
|
||||
Lumberjack House: The lumberjack house
|
||||
Lake Hylia Fortune Teller: The building NW Lake Hylia
|
||||
Kakariko Gamble Game: The old Kakariko gambling den
|
||||
Waterfall of Wishing: Going behind the waterfall
|
||||
Capacity Upgrade: The cave on the island
|
||||
Bonk Rock Cave: The rock pile near Sanctuary
|
||||
Graveyard Cave: The graveyard ledge
|
||||
Checkerboard Cave: The NE desert ledge
|
||||
Cave 45: The ledge south of haunted grove
|
||||
Kings Grave: The northeastmost grave
|
||||
Bonk Fairy (Light): The rock pile near your home
|
||||
Hookshot Fairy: A cave on east DM
|
||||
Bonk Fairy (Dark): The rock pile near the old bomb shop
|
||||
Dark Sanctuary Hint: The dark sanctuary cave
|
||||
Dark Lake Hylia Fairy: The cave NE dark Lake Hylia
|
||||
C-Shaped House: The NE house in Village of Outcasts
|
||||
Big Bomb Shop: The old bomb shop
|
||||
Dark Death Mountain Fairy: The SW cave on dark DM
|
||||
Dark Lake Hylia Shop: The building NW dark Lake Hylia
|
||||
Dark World Shop: The hammer sealed building
|
||||
Red Shield Shop: The fenced in building
|
||||
Mire Shed: The western hut in the mire
|
||||
East Dark World Hint: The dark cave near the eastmost portal
|
||||
Dark Desert Hint: The cave east of the mire
|
||||
Spike Cave: The ledge cave on west dark DM
|
||||
Palace of Darkness Hint: The building south of Kiki
|
||||
Dark Lake Hylia Ledge Spike Cave: The rock SE dark Lake Hylia
|
||||
Cave Shop (Dark Death Mountain): The base of east dark DM
|
||||
Dark World Potion Shop: The building near the catfish
|
||||
Archery Game: The old archery game
|
||||
Dark World Lumberjack Shop: The northmost Dark World building
|
||||
Hype Cave: The cave south of the old bomb shop
|
||||
Brewery: The Village of Outcasts building with no door
|
||||
Dark Lake Hylia Ledge Hint: The open cave SE dark Lake Hylia
|
||||
Chest Game: The westmost building in the Village of Outcasts
|
||||
Dark Desert Fairy: The eastern hut in the mire
|
||||
Dark Lake Hylia Ledge Fairy: The sealed cave SE dark Lake Hylia
|
||||
Fortune Teller (Dark): The building NE the Village of Outcasts
|
||||
Sanctuary: Sanctuary
|
||||
Lumberjack Tree Cave: The cave Behind Lumberjacks
|
||||
Lost Woods Hideout Stump: The stump in Lost Woods
|
||||
North Fairy Cave: The cave East of Graveyard
|
||||
Bat Cave Cave: The cave in eastern Kakariko
|
||||
Kakariko Well Cave: The cave in northern Kakariko
|
||||
Hyrule Castle Secret Entrance Stairs: The tunnel near the castle
|
||||
Skull Woods First Section Door: The southeastmost skull
|
||||
Skull Woods Second Section Door (East): The central open skull
|
||||
Skull Woods Second Section Door (West): The westmost open skull
|
||||
Desert Palace Entrance (East): The eastern building in the desert
|
||||
Turtle Rock Isolated Ledge Entrance: The isolated ledge on east dark DM
|
||||
Bumper Cave (Top): The upper Bumper Cave
|
||||
Hookshot Cave Back Entrance: The stairs on the floating island
|
||||
|
||||
Destination Entrance Naming:
|
||||
|
||||
Hyrule Castle: Hyrule Castle (all three entrances)
|
||||
Eastern Palace: Eastern Palace
|
||||
Desert Palace: Desert Palace (all four entrances, including final)
|
||||
Tower of Hera: Tower of Hera
|
||||
Palace of Darkness: Palace of Darkness
|
||||
Swamp Palace: Swamp Palace
|
||||
Skull Woods: Skull Woods (any entrance including final)
|
||||
Thieves' Town: Thieves' Town
|
||||
Ice Palace: Ice Palace
|
||||
Misery Mire: Misery Mire
|
||||
Turtle Rock: Turtle Rock (all four entrances)
|
||||
Ganon's Tower: Ganon's Tower
|
||||
Castle Tower: Agahnim's Tower
|
||||
A connector: Paradox Cave, Spectacle Rock Cave, Hookshot Cave, Superbunny Cave, Spiral Cave, Old Man Fetch Cave, Old Man House, Elder House, Quarreling Brothers' House, Bumper Cave, DM Fairy Ascent Cave, DM Exit Cave
|
||||
A bounty of five items: Mini-moldorm cave, Hype Cave, Blind's Hideout
|
||||
Sahasrahla: Sahasrahla
|
||||
A cave with two items: Mire hut, Waterfall Fairy, Pyramid Fairy
|
||||
A fairy fountain: Any healer fairy cave, either bonk cave with four fairies, the "long fairy" cave
|
||||
A common shop: Any shop that sells bombs by default
|
||||
The rare shop: The shop that sells the Red Shield by default
|
||||
The potion shop: Potion Shop
|
||||
The bomb shop: Bomb Shop
|
||||
A fortune teller: Any of the three fortune tellers
|
||||
A house with a chest: Chicken Lady's house, C-House, Brewery
|
||||
A cave with an item: Checkerboard cave, Hammer Pegs cave, Cave 45, Graveyard Ledge cave
|
||||
A cave with a chest: Sanc Bonk Rock Cave, Cape Grave Cave, Ice Rod Cave, Aginah's Cave
|
||||
The dam: Watergate
|
||||
The sick kid: Sick Kid
|
||||
The library: Library
|
||||
Mimic Cave: Mimic Cave
|
||||
Spike Cave: Spike Cave
|
||||
A game of 16 chests: VoO chest game (for the item)
|
||||
A storyteller: The four DW NPCs who charge 20 rupees for a hint as well as the PoD Bdlg guy who gives a free hint
|
||||
A cave with some cash: 20 rupee cave, 50 rupee cave (both have thieves and some pots)
|
||||
A game of chance: Gambling game (just for cash, no items)
|
||||
A game of skill: Archery minigame
|
||||
The queen of fairies: Capacity Upgrade Fairy
|
||||
A drop's exit: Sanctuary, LW Thieves' Hideout, Kakariko Well, Magic Bat, Useless Fairy, Uncle Tunnel, Ganon drop exit
|
||||
A restock room: The Kakariko bomb/arrow restock room
|
||||
The tavern: The Kakariko tavern
|
||||
The grass man: The Kakariko man with many beds
|
||||
A cold bee: The "wrong side" of Ice Rod cave where you can get a Good Bee
|
||||
Fairies deep in a cave: Hookshot Fairy
|
||||
|
||||
Location naming reference:
|
||||
|
||||
Mushroom: in the woods
|
||||
Master Sword Pedestal: at the pedestal
|
||||
Bottle Merchant: with a merchant
|
||||
Stumpy: with tree boy
|
||||
Flute Spot: underground
|
||||
Digging Game: underground
|
||||
Lake Hylia Island: on an island
|
||||
Floating Island: on an island
|
||||
Bumper Cave Ledge: on a ledge
|
||||
Spectacle Rock: atop a rock
|
||||
Maze Race: at the race
|
||||
Desert Ledge: in the desert
|
||||
Pyramid: on the pyramid
|
||||
Catfish: with a catfish
|
||||
Ether Tablet: at a monument
|
||||
Bombos Tablet: at a monument
|
||||
Hobo: with the hobo
|
||||
Zora's Ledge: near Zora
|
||||
King Zora: at a high price
|
||||
Sunken Treasure: underwater
|
||||
Floodgate Chest: in the dam
|
||||
Blacksmith: with the smith
|
||||
Purple Chest: from a box
|
||||
Old Man: with the old man
|
||||
Link's Uncle: with your uncle
|
||||
Secret Passage: near your uncle
|
||||
Kakariko Well (5 items): in a well
|
||||
Lost Woods Hideout: near a thief
|
||||
Lumberjack Tree: in a hole
|
||||
Magic Bat: with the bat
|
||||
Paradox Cave (7 items): in a cave with seven chests
|
||||
Blind's Hideout (5 items): in a basement
|
||||
Mini Moldorm Cave (5 items): near Moldorms
|
||||
Hype Cave (4 back chests): near a bat-like man
|
||||
Hype Cave - Generous Guy: with a bat-like man
|
||||
Hookshot Cave (4 items): across pits
|
||||
Sahasrahla's Hut (chests in back): near the elder
|
||||
Sahasrahla: with the elder
|
||||
Waterfall Fairy (2 items): near a fairy
|
||||
Pyramid Fairy (2 items): near a fairy
|
||||
Mire Shed (2 items): near sparks
|
||||
Superbunny Cave (2 items): in a connection
|
||||
Spiral Cave: in spiral cave
|
||||
Kakariko Tavern: in the bar
|
||||
Link's House: in your home
|
||||
Sick Kid: with the sick
|
||||
Library: near books
|
||||
Potion Shop: near potions
|
||||
Spike Cave: beyond spikes
|
||||
Mimic Cave: in a cave of mimicry
|
||||
Chest Game: as a prize
|
||||
Chicken House: near poultry
|
||||
Aginah's Cave: with Aginah
|
||||
Ice Rod Cave: in a frozen cave
|
||||
Brewery: alone in a home
|
||||
C-Shaped House: alone in a home
|
||||
Spectacle Rock Cave: alone in a cave
|
||||
King's Tomb: alone in a cave
|
||||
Cave 45: alone in a cave
|
||||
Graveyard Cave: alone in a cave
|
||||
Checkerboard Cave: alone in a cave
|
||||
Bonk Rock Cave: alone in a cave
|
||||
Peg Cave: alone in a cave
|
||||
Sanctuary: in Sanctuary
|
||||
Hyrule Castle - Boomerang Chest: in Hyrule Castle
|
||||
Hyrule Castle - Map Chest: in Hyrule Castle
|
||||
Hyrule Castle - Zelda's Chest: in Hyrule Castle
|
||||
Sewers - Dark Cross: in the sewers
|
||||
Sewers - Secret Room (3 items): in the sewers
|
||||
Eastern Palace - Boss: with the Armos
|
||||
Eastern Palace (otherwise, 5 items): in Eastern Palace
|
||||
Desert Palace - Boss: with Lanmolas
|
||||
Desert Palace (otherwise, 5 items): in Desert Palace
|
||||
Tower of Hera - Boss: with Moldorm
|
||||
Tower of Hera (otherwise, 5 items): in Tower of Hera
|
||||
Castle Tower (2 items): in Castle Tower
|
||||
Palace of Darkness - Boss: with Helmasaur King
|
||||
Palace of Darkness (otherwise, 13 items): in Palace of Darkness
|
||||
Swamp Palace - Boss: with Arrghus
|
||||
Swamp Palace (otherwise, 9 items): in Swamp Palace
|
||||
Skull Woods - Bridge Room: near Mothula
|
||||
Skull Woods - Boss: with Mothula
|
||||
Skull Woods (otherwise, 6 items): in Skull Woods
|
||||
Thieves' Town - Boss: with Blind
|
||||
Thieves' Town (otherwise, 7 items): in Thieves' Town
|
||||
Ice Palace - Boss: with Kholdstare
|
||||
Ice Palace (otherwise, 7 items): in Ice Palace
|
||||
Misery Mire - Boss: with Vitreous
|
||||
Misery Mire (otherwise, 7 items): in Misery Mire
|
||||
Turtle Rock - Boss: with Trinexx
|
||||
Turtle Rock (otherwise, 11 items): in Turtle Rock
|
||||
Ganons Tower (after climb, 4 items): atop Ganon's Tower
|
||||
Ganon's Tower (otherwise, 23 items): in Ganon's Tower
|
||||
269
FF1Client.py
Normal file
269
FF1Client.py
Normal file
@@ -0,0 +1,269 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
from typing import List
|
||||
|
||||
|
||||
import Utils
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
|
||||
get_base_parser
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart ff1_connector.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure ff1_connector.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart ff1_connector.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
DISPLAY_MSGS = True
|
||||
|
||||
|
||||
class FF1CommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_nes(self):
|
||||
"""Check NES Connection State"""
|
||||
if isinstance(self.ctx, FF1Context):
|
||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
||||
|
||||
def _cmd_toggle_msgs(self):
|
||||
"""Toggle displaying messages in 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
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.nes_streams: (StreamReader, StreamWriter) = None
|
||||
self.nes_sync_task = None
|
||||
self.messages = {}
|
||||
self.locations_array = None
|
||||
self.nes_status = CONNECTION_INITIAL_STATUS
|
||||
self.game = 'Final Fantasy'
|
||||
self.awaiting_rom = False
|
||||
self.display_msgs = True
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(FF1Context, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to NES to get Player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
if DISPLAY_MSGS:
|
||||
self.messages[(time.time(), msg_id)] = msg
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
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']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == 'PrintJSON':
|
||||
print_type = args['type']
|
||||
item = args['item']
|
||||
receiving_player_id = args['receiving']
|
||||
receiving_player_name = self.player_names[receiving_player_id]
|
||||
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)}"
|
||||
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)}"
|
||||
else:
|
||||
msg = f"You sent {self.item_name_getter(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)}"
|
||||
else:
|
||||
msg = f"{sending_player_name} sent {self.item_name_getter(item.item)} to " \
|
||||
f"{receiving_player_name}"
|
||||
self._set_message(msg, item.item)
|
||||
|
||||
|
||||
def get_payload(ctx: FF1Context):
|
||||
current_time = time.time()
|
||||
return json.dumps(
|
||||
{
|
||||
"items": [item.item for item in ctx.items_received],
|
||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
||||
if key[0] > current_time - 10}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
|
||||
if locations_array == ctx.locations_array and not force:
|
||||
return
|
||||
else:
|
||||
# print("New values")
|
||||
ctx.locations_array = locations_array
|
||||
locations_checked = []
|
||||
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "StatusUpdate",
|
||||
"status": 30}
|
||||
])
|
||||
ctx.finished_game = True
|
||||
for location in ctx.missing_locations:
|
||||
# index will be - 0x100 or 0x200
|
||||
index = location
|
||||
if location < 0x200:
|
||||
# Location is a chest
|
||||
index -= 0x100
|
||||
flag = 0x04
|
||||
else:
|
||||
# Location is an NPC
|
||||
index -= 0x200
|
||||
flag = 0x02
|
||||
|
||||
# print(f"Location: {ctx.location_name_getter(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])
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "LocationChecks",
|
||||
"locations": locations_checked}
|
||||
])
|
||||
|
||||
|
||||
async def nes_sync_task(ctx: FF1Context):
|
||||
logger.info("Starting nes connector. Use /nes for status information")
|
||||
while not ctx.exit_event.is_set():
|
||||
error_status = None
|
||||
if ctx.nes_streams:
|
||||
(reader, writer) = ctx.nes_streams
|
||||
msg = get_payload(ctx).encode()
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to two fields:
|
||||
# 1. A keepalive response of the Players Name (always)
|
||||
# 2. An array representing the memory values of the locations area (if in game)
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
||||
data_decoded = json.loads(data.decode())
|
||||
# print(data_decoded)
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
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.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to NES")
|
||||
ctx.nes_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
|
||||
elif error_status:
|
||||
ctx.nes_status = error_status
|
||||
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to NES")
|
||||
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
|
||||
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
Utils.init_logging("FF1Client")
|
||||
|
||||
options = Utils.get_options()
|
||||
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
|
||||
|
||||
async def main(args):
|
||||
ctx = FF1Context(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
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.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.nes_sync_task:
|
||||
await ctx.nes_sync_task
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser()
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
colorama.deinit()
|
||||
@@ -4,39 +4,26 @@ import logging
|
||||
import json
|
||||
import string
|
||||
import copy
|
||||
import sys
|
||||
import subprocess
|
||||
import factorio_rcon
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
|
||||
import factorio_rcon
|
||||
import colorama
|
||||
import asyncio
|
||||
from queue import Queue
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger
|
||||
from MultiServer import mark_raw
|
||||
|
||||
import Utils
|
||||
import random
|
||||
from NetUtils import RawJSONtoTextParser, NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||
|
||||
from worlds.factorio.Technologies import lookup_id_to_name
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||
|
||||
rcon_port = 24242
|
||||
rcon_password = ''.join(random.choice(string.ascii_letters) for x in range(32))
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
|
||||
get_base_parser
|
||||
from MultiServer import mark_raw
|
||||
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||
|
||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
|
||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||
options = Utils.get_options()
|
||||
executable = options["factorio_options"]["executable"]
|
||||
bin_dir = os.path.dirname(executable)
|
||||
if not os.path.isdir(bin_dir):
|
||||
raise FileNotFoundError(bin_dir)
|
||||
if not os.path.exists(executable):
|
||||
if os.path.exists(executable + ".exe"):
|
||||
executable = executable + ".exe"
|
||||
else:
|
||||
raise FileNotFoundError(executable)
|
||||
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *sys.argv[1:])
|
||||
from worlds.factorio import Factorio
|
||||
|
||||
|
||||
class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
@@ -46,95 +33,160 @@ class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_factorio(self, text: str) -> bool:
|
||||
"""Send the following command to the bound Factorio Server."""
|
||||
if self.ctx.rcon_client:
|
||||
# TODO: Print the command non-silently only for race seeds, or otherwise block anything but /factorio /save in race seeds.
|
||||
self.ctx.print_to_game(f"/factorio {text}")
|
||||
result = self.ctx.rcon_client.send_command(text)
|
||||
if result:
|
||||
self.output(result)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _cmd_connect(self, address: str = "") -> bool:
|
||||
"""Connect to a MultiWorld Server"""
|
||||
if not self.ctx.auth:
|
||||
if self.ctx.rcon_client:
|
||||
get_info(self.ctx, self.ctx.rcon_client) # retrieve current auth code
|
||||
else:
|
||||
self.output("Cannot connect to a server with unknown own identity, bridge to Factorio first.")
|
||||
return super(FactorioCommandProcessor, self)._cmd_connect(address)
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.ctx.awaiting_bridge = True
|
||||
|
||||
|
||||
class FactorioContext(CommonContext):
|
||||
command_processor = FactorioCommandProcessor
|
||||
game = "Factorio"
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FactorioContext, self).__init__(*args, **kwargs)
|
||||
self.send_index = 0
|
||||
# updated by spinup server
|
||||
mod_version: Utils.Version = Utils.Version(0, 0, 0)
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(FactorioContext, self).__init__(server_address, password)
|
||||
self.send_index: int = 0
|
||||
self.rcon_client = None
|
||||
self.awaiting_bridge = False
|
||||
self.raw_json_text_parser = RawJSONtoTextParser(self)
|
||||
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):
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(FactorioContext, self).server_auth(password_requested)
|
||||
|
||||
await self.send_msgs([{"cmd": 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
'tags': ['AP'],
|
||||
'uuid': Utils.get_unique_identifier(), 'game': "Factorio"
|
||||
}])
|
||||
if self.rcon_client:
|
||||
await get_info(self, self.rcon_client) # retrieve current auth code
|
||||
else:
|
||||
raise Exception("Cannot connect to a server with unknown own identity, "
|
||||
"bridge to Factorio first.")
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def on_print(self, args: dict):
|
||||
logger.info(args["text"])
|
||||
super(FactorioContext, self).on_print(args)
|
||||
if self.rcon_client:
|
||||
cleaned_text = args['text'].replace('"', '')
|
||||
self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] "
|
||||
f"{cleaned_text}\")")
|
||||
self.print_to_game(args['text'])
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if not self.found_items and args.get("type", None) == "ItemSend" and args["receiving"] == args["sending"]:
|
||||
pass # don't want info on other player's local pickups.
|
||||
text = self.raw_json_text_parser(copy.deepcopy(args["data"]))
|
||||
logger.info(text)
|
||||
if self.rcon_client:
|
||||
text = self.factorio_json_text_parser(args["data"])
|
||||
cleaned_text = text.replace('"', '')
|
||||
self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] "
|
||||
f"{cleaned_text}\")")
|
||||
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
|
||||
self.print_to_game(text)
|
||||
super(FactorioContext, self).on_print_json(args)
|
||||
|
||||
@property
|
||||
def savegame_name(self) -> str:
|
||||
return f"AP_{self.seed_name}_{self.auth}.zip"
|
||||
return f"AP_{self.seed_name}_{self.auth}_Save.zip"
|
||||
|
||||
def print_to_game(self, text):
|
||||
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
||||
f"{text}")
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
if self.rcon_client:
|
||||
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
|
||||
super(FactorioContext, self).on_deathlink(data)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected", "RoomUpdate"}:
|
||||
# catch up sync anything that is already cleared.
|
||||
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.info(f"EnergyLink: Received {gained_text}. "
|
||||
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
||||
self.rcon_client.send_command(f"/ap-energylink {gained}")
|
||||
|
||||
|
||||
async def game_watcher(ctx: FactorioContext):
|
||||
bridge_logger = logging.getLogger("FactorioWatcher")
|
||||
from worlds.factorio.Technologies import lookup_id_to_name
|
||||
next_bridge = time.perf_counter() + 1
|
||||
try:
|
||||
while not ctx.exit_event.is_set():
|
||||
if ctx.awaiting_bridge and ctx.rcon_client:
|
||||
# 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:
|
||||
logger.warning(f"Connected World is not the expected one {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:
|
||||
logger.warning(f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
|
||||
bridge_logger.warning(
|
||||
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
|
||||
else:
|
||||
data = data["info"]
|
||||
research_data = data["research_done"]
|
||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||
victory = data["victory"]
|
||||
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}])
|
||||
ctx.finished_game = True
|
||||
|
||||
if ctx.locations_checked != research_data:
|
||||
bridge_logger.info(
|
||||
bridge_logger.debug(
|
||||
f"New researches done: "
|
||||
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
|
||||
ctx.locations_checked = research_data
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
||||
await asyncio.sleep(1)
|
||||
death_link_tick = data.get("death_link_tick", 0)
|
||||
if death_link_tick != ctx.death_link_tick:
|
||||
ctx.death_link_tick = death_link_tick
|
||||
if "DeathLink" in ctx.tags:
|
||||
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.info(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
@@ -160,7 +212,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
if not os.path.exists(savegame_name):
|
||||
logger.info(f"Creating savegame {savegame_name}")
|
||||
subprocess.run((
|
||||
executable, "--create", savegame_name
|
||||
executable, "--create", savegame_name, "--preset", "archipelago"
|
||||
))
|
||||
factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name,
|
||||
*(str(elem) for elem in server_args)),
|
||||
@@ -180,26 +232,34 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
|
||||
while not factorio_queue.empty():
|
||||
msg = factorio_queue.get()
|
||||
factorio_server_logger.info(msg)
|
||||
factorio_queue.task_done()
|
||||
|
||||
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
||||
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
||||
# trigger lua interface confirmation
|
||||
ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
|
||||
ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
|
||||
if not ctx.server:
|
||||
logger.info("Established bridge to Factorio Server. "
|
||||
"Ready to connect to Archipelago via /connect")
|
||||
|
||||
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
|
||||
ctx.awaiting_bridge = True
|
||||
factorio_server_logger.debug(msg)
|
||||
else:
|
||||
factorio_server_logger.info(msg)
|
||||
if ctx.rcon_client:
|
||||
commands = {}
|
||||
while ctx.send_index < len(ctx.items_received):
|
||||
transfer_item: NetworkItem = ctx.items_received[ctx.send_index]
|
||||
item_id = transfer_item.item
|
||||
player_name = ctx.player_names[transfer_item.player]
|
||||
if item_id not in lookup_id_to_name:
|
||||
logging.error(f"Cannot send unknown item ID: {item_id}")
|
||||
if item_id not in Factorio.item_id_to_name:
|
||||
factorio_server_logger.error(f"Cannot send unknown item ID: {item_id}")
|
||||
else:
|
||||
item_name = lookup_id_to_name[item_id]
|
||||
item_name = Factorio.item_id_to_name[item_id]
|
||||
factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.")
|
||||
ctx.rcon_client.send_command(f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}')
|
||||
commands[ctx.send_index] = f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}'
|
||||
ctx.send_index += 1
|
||||
if commands:
|
||||
ctx.rcon_client.send_commands(commands)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
@@ -210,20 +270,28 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
|
||||
finally:
|
||||
factorio_process.terminate()
|
||||
factorio_process.wait(5)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def factorio_spinup_server(ctx: FactorioContext):
|
||||
async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
savegame_name = os.path.abspath("Archipelago.zip")
|
||||
if not os.path.exists(savegame_name):
|
||||
logger.info(f"Creating savegame {savegame_name}")
|
||||
subprocess.run((
|
||||
executable, "--create", savegame_name, "--preset", "archipelago"
|
||||
executable, "--create", savegame_name
|
||||
))
|
||||
factorio_process = subprocess.Popen(
|
||||
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
|
||||
@@ -241,55 +309,63 @@ async def factorio_spinup_server(ctx: FactorioContext):
|
||||
while not factorio_queue.empty():
|
||||
msg = factorio_queue.get()
|
||||
factorio_server_logger.info(msg)
|
||||
if "Loading mod AP-" in msg and msg.endswith("(data.lua)"):
|
||||
parts = msg.split()
|
||||
ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split(".")))
|
||||
elif "Write data path: " in msg:
|
||||
ctx.write_data_path = Utils.get_text_between(msg, "Write data path: ", " [")
|
||||
if "AppData" in ctx.write_data_path:
|
||||
logger.warning("It appears your mods are loaded from Appdata, "
|
||||
"this can lead to problems with multiple Factorio instances. "
|
||||
"If this is the case, you will get a file locked error running Factorio.")
|
||||
if not rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
||||
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
||||
get_info(ctx, rcon_client)
|
||||
|
||||
|
||||
if ctx.mod_version == ctx.__class__.mod_version:
|
||||
raise Exception("No Archipelago mod was loaded. Aborting.")
|
||||
await get_info(ctx, rcon_client)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.error("Aborted Factorio Server Bridge")
|
||||
logger.exception(e)
|
||||
logger.error("Aborted Factorio Server Bridge")
|
||||
ctx.exit_event.set()
|
||||
|
||||
else:
|
||||
logger.info(f"Got World Information from AP Mod for seed {ctx.seed_name} in slot {ctx.auth}")
|
||||
|
||||
logger.info(
|
||||
f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}")
|
||||
return True
|
||||
finally:
|
||||
factorio_process.terminate()
|
||||
factorio_process.wait(5)
|
||||
return False
|
||||
|
||||
|
||||
async def main(ui=None):
|
||||
ctx = FactorioContext(None, None, True)
|
||||
async def main(args):
|
||||
ctx = FactorioContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if ui:
|
||||
input_task = None
|
||||
ui_app = ui(ctx)
|
||||
ui_task = asyncio.create_task(ui_app.async_run(), name="UI")
|
||||
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:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
ui_task = None
|
||||
if sys.stdin:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
|
||||
await factorio_server_task
|
||||
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
|
||||
progression_watcher = asyncio.create_task(
|
||||
game_watcher(ctx), name="FactorioProgressionWatcher")
|
||||
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")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await progression_watcher
|
||||
await factorio_server_task
|
||||
await progression_watcher
|
||||
await factorio_server_task
|
||||
|
||||
if ctx.server and not ctx.server.socket.closed:
|
||||
await ctx.server.socket.close()
|
||||
if ctx.server_task is not None:
|
||||
await ctx.server_task
|
||||
|
||||
while ctx.input_requests > 0:
|
||||
ctx.input_queue.put_nowait(None)
|
||||
ctx.input_requests -= 1
|
||||
await ctx.shutdown()
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
@@ -302,19 +378,42 @@ 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)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_base_parser(description="Optional arguments to FactorioClient follow. "
|
||||
"Remaining arguments get passed into bound Factorio instance."
|
||||
"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.')
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
rcon_port = args.rcon_port
|
||||
rcon_password = args.rcon_password if args.rcon_password else ''.join(
|
||||
random.choice(string.ascii_letters) for x in range(32))
|
||||
|
||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||
options = Utils.get_options()
|
||||
executable = options["factorio_options"]["executable"]
|
||||
|
||||
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.")
|
||||
if os.path.isdir(executable): # user entered a path to a directory, let's find the executable therein
|
||||
executable = os.path.join(executable, "factorio")
|
||||
if not os.path.isfile(executable):
|
||||
if os.path.isfile(executable + ".exe"):
|
||||
executable = executable + ".exe"
|
||||
else:
|
||||
raise FileNotFoundError(f"Path {executable} is not an executable file.")
|
||||
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
colorama.deinit()
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import os
|
||||
import logging
|
||||
import sys
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
if getattr(sys, "frozen", False):
|
||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
|
||||
filename=os.path.join("logs", "FactorioClient.txt"), filemode="w")
|
||||
else:
|
||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
|
||||
logging.getLogger().addHandler(logging.FileHandler(os.path.join("logs", "FactorioClient.txt"), "w"))
|
||||
os.environ["KIVY_NO_CONSOLELOG"] = "1"
|
||||
os.environ["KIVY_NO_FILELOG"] = "1"
|
||||
os.environ["KIVY_NO_ARGS"] = "1"
|
||||
|
||||
import asyncio
|
||||
from CommonClient import logger
|
||||
from FactorioClient import main
|
||||
|
||||
|
||||
from kivy.app import App
|
||||
from kivy.uix.label import Label
|
||||
from kivy.base import ExceptionHandler, ExceptionManager, Config
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.textinput import TextInput
|
||||
from kivy.uix.recycleview import RecycleView
|
||||
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
|
||||
from kivy.lang import Builder
|
||||
|
||||
|
||||
class FactorioManager(App):
|
||||
def __init__(self, ctx):
|
||||
super(FactorioManager, self).__init__()
|
||||
self.ctx = ctx
|
||||
self.commandprocessor = ctx.command_processor(ctx)
|
||||
self.icon = r"data/icon.png"
|
||||
|
||||
def build(self):
|
||||
self.grid = GridLayout()
|
||||
self.grid.cols = 1
|
||||
self.tabs = TabbedPanel()
|
||||
self.tabs.default_tab_text = "All"
|
||||
self.title = "Archipelago Factorio Client"
|
||||
pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("FactorioServer", "Factorio Server Log"),
|
||||
("FactorioWatcher", "Bridge Data Log"),
|
||||
]
|
||||
self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name) for logger_name, name in pairs))
|
||||
for logger_name, display_name in pairs:
|
||||
bridge_logger = logging.getLogger(logger_name)
|
||||
panel = TabbedPanelItem(text=display_name)
|
||||
panel.content = UILog(bridge_logger)
|
||||
self.tabs.add_widget(panel)
|
||||
|
||||
self.grid.add_widget(self.tabs)
|
||||
textinput = TextInput(size_hint_y=None, height=30, multiline=False)
|
||||
textinput.bind(on_text_validate=self.on_message)
|
||||
self.grid.add_widget(textinput)
|
||||
self.commandprocessor("/help")
|
||||
return self.grid
|
||||
|
||||
def on_stop(self):
|
||||
self.ctx.exit_event.set()
|
||||
|
||||
def on_message(self, textinput: TextInput):
|
||||
try:
|
||||
input_text = textinput.text.strip()
|
||||
textinput.text = ""
|
||||
|
||||
if self.ctx.input_requests > 0:
|
||||
self.ctx.input_requests -= 1
|
||||
self.ctx.input_queue.put_nowait(input_text)
|
||||
elif input_text:
|
||||
self.commandprocessor(input_text)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
|
||||
|
||||
class LogtoUI(logging.Handler):
|
||||
def __init__(self, on_log):
|
||||
super(LogtoUI, self).__init__(logging.DEBUG)
|
||||
self.on_log = on_log
|
||||
|
||||
def handle(self, record: logging.LogRecord) -> None:
|
||||
self.on_log(record)
|
||||
|
||||
|
||||
class UILog(RecycleView):
|
||||
cols = 1
|
||||
|
||||
def __init__(self, *loggers_to_handle, **kwargs):
|
||||
super(UILog, self).__init__(**kwargs)
|
||||
self.data = []
|
||||
for logger in loggers_to_handle:
|
||||
logger.addHandler(LogtoUI(self.on_log))
|
||||
|
||||
def on_log(self, record: logging.LogRecord) -> None:
|
||||
self.data.append({"text": record.getMessage()})
|
||||
|
||||
|
||||
class E(ExceptionHandler):
|
||||
def handle_exception(self, inst):
|
||||
logger.exception(inst)
|
||||
return ExceptionManager.RAISE
|
||||
|
||||
ExceptionManager.add_handler(E())
|
||||
|
||||
|
||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||
Builder.load_string('''
|
||||
<TabbedPanel>
|
||||
tab_width: 200
|
||||
<Row@Label>:
|
||||
canvas.before:
|
||||
Color:
|
||||
rgba: 0.2, 0.2, 0.2, 1
|
||||
Rectangle:
|
||||
size: self.size
|
||||
pos: self.pos
|
||||
text_size: self.width, None
|
||||
size_hint_y: None
|
||||
height: self.texture_size[1]
|
||||
font_size: dp(20)
|
||||
<UILog>:
|
||||
viewclass: 'Row'
|
||||
scroll_y: 0
|
||||
effect_cls: "ScrollEffect"
|
||||
RecycleBoxLayout:
|
||||
default_size: None, dp(20)
|
||||
default_size_hint: 1, None
|
||||
size_hint_y: None
|
||||
height: self.minimum_height
|
||||
orientation: 'vertical'
|
||||
spacing: dp(3)
|
||||
''')
|
||||
|
||||
if __name__ == '__main__':
|
||||
loop = asyncio.get_event_loop()
|
||||
ui_app = FactorioManager
|
||||
loop.run_until_complete(main(ui_app))
|
||||
loop.close()
|
||||
594
Fill.py
594
Fill.py
@@ -2,146 +2,198 @@ import logging
|
||||
import typing
|
||||
import collections
|
||||
import itertools
|
||||
from collections import Counter, deque
|
||||
|
||||
from BaseClasses import CollectionState, PlandoItem, Location, MultiWorld
|
||||
from worlds.alttp.Items import ItemFactory
|
||||
from worlds.alttp.Regions import key_drop_data
|
||||
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item
|
||||
|
||||
from worlds.AutoWorld import call_all
|
||||
|
||||
|
||||
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:
|
||||
if world.accessibility[item_to_place.player] == 'none':
|
||||
spot_to_fill: typing.Optional[Location] = None
|
||||
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,
|
||||
# poping 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] != 'none' and world.can_beat_game():
|
||||
logging.warning(
|
||||
f'Not all items placed. Game beatable anyway. (Could not place {item_to_place})')
|
||||
continue
|
||||
# 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
|
||||
|
||||
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)}')
|
||||
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
|
||||
|
||||
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, gftower_trash=False, 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 = []
|
||||
localrestitempool = {player: [] for player in range(1, world.players + 1)}
|
||||
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.name in world.local_items[item.player]:
|
||||
elif item.never_exclude: # 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)
|
||||
elif item.name in world.non_local_items[item.player].value:
|
||||
nonlocalrestitempool.append(item)
|
||||
else:
|
||||
restitempool.append(item)
|
||||
|
||||
standard_keyshuffle_players = set()
|
||||
call_all(world, "fill_hook", progitempool, nonexcludeditempool,
|
||||
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
|
||||
|
||||
# fill in gtower locations with trash first
|
||||
for player in world.alttp_player_ids:
|
||||
if not gftower_trash or not world.ganonstower_vanilla[player] or \
|
||||
world.logic[player] in {'owglitches', 'hybridglitches', "nologic"}:
|
||||
gtower_trash_count = 0
|
||||
elif 'triforcehunt' in world.goal[player] and ('local' in world.goal[player] or world.players == 1):
|
||||
gtower_trash_count = world.random.randint(world.crystals_needed_for_gt[player] * 2,
|
||||
world.crystals_needed_for_gt[player] * 4)
|
||||
else:
|
||||
gtower_trash_count = world.random.randint(0, world.crystals_needed_for_gt[player] * 2)
|
||||
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
||||
loc_type: [] for loc_type in LocationProgressType}
|
||||
|
||||
if gtower_trash_count:
|
||||
gtower_locations = [location for location in fill_locations if
|
||||
'Ganons Tower' in location.name and location.player == player]
|
||||
world.random.shuffle(gtower_locations)
|
||||
trashcnt = 0
|
||||
localrest = localrestitempool[player]
|
||||
if localrest:
|
||||
gt_item_pool = restitempool + localrest
|
||||
world.random.shuffle(gt_item_pool)
|
||||
else:
|
||||
gt_item_pool = restitempool.copy()
|
||||
for loc in fill_locations:
|
||||
locations[loc.progress_type].append(loc)
|
||||
|
||||
while gtower_locations and gt_item_pool and trashcnt < gtower_trash_count:
|
||||
spot_to_fill = gtower_locations.pop()
|
||||
item_to_place = gt_item_pool.pop()
|
||||
if item_to_place in localrest:
|
||||
localrest.remove(item_to_place)
|
||||
else:
|
||||
restitempool.remove(item_to_place)
|
||||
world.push_item(spot_to_fill, item_to_place, False)
|
||||
fill_locations.remove(spot_to_fill)
|
||||
trashcnt += 1
|
||||
if world.mode[player] == 'standard' and world.keyshuffle[player] is True:
|
||||
standard_keyshuffle_players.add(player)
|
||||
prioritylocations = locations[LocationProgressType.PRIORITY]
|
||||
defaultlocations = locations[LocationProgressType.DEFAULT]
|
||||
excludedlocations = locations[LocationProgressType.EXCLUDED]
|
||||
|
||||
fill_restrictive(world, world.state, prioritylocations, progitempool)
|
||||
if prioritylocations:
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
# Make sure the escape small key is placed first in standard with key shuffle to prevent running out of spots
|
||||
if standard_keyshuffle_players:
|
||||
progitempool.sort(
|
||||
key=lambda item: 1 if item.name == 'Small Key (Hyrule Castle)' and
|
||||
item.player in standard_keyshuffle_players else 0)
|
||||
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')
|
||||
|
||||
world.random.shuffle(fill_locations)
|
||||
fill_restrictive(world, world.state, fill_locations, progitempool)
|
||||
if nonexcludeditempool:
|
||||
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]
|
||||
@@ -152,29 +204,45 @@ def distribute_items_restrictive(world: MultiWorld, gftower_trash=False, fill_lo
|
||||
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)
|
||||
|
||||
world.random.shuffle(fill_locations)
|
||||
for item_to_place in nonlocalrestitempool:
|
||||
for i, location in enumerate(defaultlocations):
|
||||
if location.player != item_to_place.player:
|
||||
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 {defaultlocations}, tossing.")
|
||||
|
||||
restitempool, fill_locations = fast_fill(world, restitempool, fill_locations)
|
||||
unplaced = [item for item in progitempool + restitempool]
|
||||
unfilled = [location.name for location in fill_locations]
|
||||
world.random.shuffle(defaultlocations)
|
||||
|
||||
for location in fill_locations:
|
||||
world.push_item(location, ItemFactory('Nothing', location.player), False)
|
||||
restitempool, defaultlocations = fast_fill(
|
||||
world, restitempool, defaultlocations)
|
||||
unplaced = progitempool + restitempool
|
||||
unfilled = [location.name for location in 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
|
||||
@@ -213,7 +281,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
|
||||
@@ -224,7 +293,7 @@ def flood_items(world: MultiWorld):
|
||||
location_list = world.get_reachable_locations()
|
||||
world.random.shuffle(location_list)
|
||||
for location in location_list:
|
||||
if location.item is not None and not location.item.advancement and not location.item.smallkey and not location.item.bigkey:
|
||||
if location.item is not None and not location.item.advancement:
|
||||
# safe to replace
|
||||
replace_item = location.item
|
||||
replace_item.location = None
|
||||
@@ -234,68 +303,113 @@ 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 = {player for player in world.player_ids if world.progression_balancing[player]}
|
||||
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()
|
||||
checked_locations: typing.Set[Location] = set()
|
||||
unchecked_locations = set(world.get_locations())
|
||||
|
||||
reachable_locations_count = {player: 0 for player in world.player_ids}
|
||||
reachable_locations_count = {
|
||||
player: 0
|
||||
for player in world.player_ids
|
||||
if len(world.get_filled_locations(player)) != 0
|
||||
}
|
||||
total_locations_count = Counter(location.player for location in world.get_locations() if not location.locked)
|
||||
balanceable_players = {player for player in balanceable_players if total_locations_count[player]}
|
||||
sphere_num = 1
|
||||
moved_item_count = 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
|
||||
# The 10% threshold can be modified for "progression balancing strength"
|
||||
# right now it approximates the old 20/216 bound.
|
||||
threshold_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]),
|
||||
reachable_locations_count)) - 0.10
|
||||
logging.debug(f"Threshold: {threshold_percentage}")
|
||||
balancing_players = {player for player, reachables in reachable_locations_count.items() if
|
||||
reachables < threshold and player in balanceable_players}
|
||||
item_percentage(player, reachables) < threshold_percentage and player in balanceable_players}
|
||||
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_percentage
|
||||
for player, reachables in balancing_reachables.items()):
|
||||
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]
|
||||
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)
|
||||
@@ -305,7 +419,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_percentage:
|
||||
items_to_replace.append(testing)
|
||||
|
||||
replaced_items = False
|
||||
@@ -317,6 +432,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:
|
||||
@@ -326,6 +442,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
|
||||
@@ -333,10 +450,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:
|
||||
@@ -347,10 +466,11 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
if world.has_beaten_game(state):
|
||||
break
|
||||
elif not sphere_locations:
|
||||
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
||||
logging.warning("Progression Balancing ran out of paths.")
|
||||
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:
|
||||
@@ -363,76 +483,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 = ItemFactory(placement.item, player)
|
||||
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_names[player]})") from e
|
||||
raise Exception(
|
||||
f"Error running plando for player {player} ({world.player_name[player]})") from e
|
||||
|
||||
670
Generate.py
Normal file
670
Generate.py
Normal file
@@ -0,0 +1,670 @@
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import typing
|
||||
import os
|
||||
from collections import Counter
|
||||
import string
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
from worlds.alttp import Options as LttPOptions
|
||||
from worlds.generic import PlandoConnection
|
||||
from Utils import parse_yaml, 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
|
||||
import Options
|
||||
from worlds.alttp import Bosses
|
||||
from worlds.alttp.Text import TextTable
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import copy
|
||||
|
||||
categories = set(AutoWorldRegister.world_types)
|
||||
|
||||
|
||||
def mystery_argparse():
|
||||
options = get_options()
|
||||
defaults = options["generator"]
|
||||
|
||||
def resolve_path(path: str, resolver: typing.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"],
|
||||
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=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.") # 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')
|
||||
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
|
||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
||||
parser.add_argument('--plando', default=defaults["plando_options"],
|
||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
||||
args = parser.parse_args()
|
||||
if not os.path.isabs(args.weights_file_path):
|
||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||
if not os.path.isabs(args.meta_file_path):
|
||||
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(",")}
|
||||
return args, options
|
||||
|
||||
|
||||
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):
|
||||
if not args:
|
||||
args, options = mystery_argparse()
|
||||
|
||||
seed = get_seed(args.seed)
|
||||
random.seed(seed)
|
||||
seed_name = get_seed_name(random)
|
||||
|
||||
if args.race:
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
weights_cache = {}
|
||||
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)
|
||||
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')}")
|
||||
|
||||
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)
|
||||
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]
|
||||
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
||||
del(meta_weights["meta_description"])
|
||||
if args.samesettings:
|
||||
raise Exception("Cannot mix --samesettings with --meta")
|
||||
else:
|
||||
meta_weights = None
|
||||
player_id = 1
|
||||
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}:
|
||||
path = os.path.join(args.player_files_path, fname)
|
||||
try:
|
||||
weights_cache[fname] = read_weights_yaml(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
|
||||
|
||||
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)}")
|
||||
|
||||
if not weights_cache:
|
||||
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||
f"A mix is also permitted.")
|
||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||
erargs.seed = seed
|
||||
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
|
||||
erargs.spoiler = args.spoiler
|
||||
erargs.race = args.race
|
||||
erargs.outputname = seed_name
|
||||
erargs.outputpath = args.outputpath
|
||||
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||
|
||||
erargs.lttp_rom = args.lttp_rom
|
||||
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()}
|
||||
player_path_cache = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||
|
||||
if meta_weights:
|
||||
for category_name, category_dict in meta_weights.items():
|
||||
for key in category_dict:
|
||||
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]:
|
||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||
else:
|
||||
weights_cache[path][category_name][key] = option
|
||||
|
||||
name_counter = Counter()
|
||||
erargs.player_settings = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
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
|
||||
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: {Counter(erargs.name.values())}")
|
||||
|
||||
if args.yaml_output:
|
||||
import yaml
|
||||
important = {}
|
||||
for option, player_settings in vars(erargs).items():
|
||||
if type(player_settings) == dict:
|
||||
if all(type(value) != list for value in player_settings.values()):
|
||||
if len(player_settings.values()) > 1:
|
||||
important[option] = {player: value for player, value in player_settings.items() if
|
||||
player <= args.yaml_output}
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
|
||||
else:
|
||||
if player_settings != "": # is not empty name
|
||||
important[option] = player_settings
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
if args.outputpath:
|
||||
os.makedirs(args.outputpath, exist_ok=True)
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
||||
yaml.dump(important, f)
|
||||
|
||||
callback(erargs, seed)
|
||||
|
||||
|
||||
def read_weights_yaml(path):
|
||||
try:
|
||||
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-sig")
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to read weights ({path})") from e
|
||||
|
||||
return parse_yaml(yaml)
|
||||
|
||||
|
||||
def interpret_on_off(value) -> bool:
|
||||
return {"on": True, "off": False}.get(value, 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:
|
||||
if option not in root:
|
||||
return value
|
||||
if type(root[option]) is list:
|
||||
return interpret_on_off(random.choices(root[option])[0])
|
||||
if type(root[option]) is not dict:
|
||||
return interpret_on_off(root[option])
|
||||
if not root[option]:
|
||||
return value
|
||||
if any(root[option].values()):
|
||||
return interpret_on_off(
|
||||
random.choices(list(root[option].keys()), weights=list(map(int, root[option].values())))[0])
|
||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||
|
||||
|
||||
def get_choice(option, root, value=None) -> typing.Any:
|
||||
if option not in root:
|
||||
return value
|
||||
if type(root[option]) is list:
|
||||
return random.choices(root[option])[0]
|
||||
if type(root[option]) is not dict:
|
||||
return root[option]
|
||||
if not root[option]:
|
||||
return value
|
||||
if any(root[option].values()):
|
||||
return random.choices(list(root[option].keys()), weights=list(map(int, root[option].values())))[0]
|
||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||
|
||||
|
||||
class SafeDict(dict):
|
||||
def __missing__(self, key):
|
||||
return '{' + key + '}'
|
||||
|
||||
|
||||
def handle_name(name: str, player: int, name_counter: Counter):
|
||||
name_counter[name] += 1
|
||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
|
||||
NUMBER=(name_counter[name] if name_counter[
|
||||
name] > 1 else ''),
|
||||
player=player,
|
||||
PLAYER=(player if player > 1 else '')))
|
||||
new_name = new_name.strip()[:16]
|
||||
if new_name == "Archipelago":
|
||||
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
||||
return new_name
|
||||
|
||||
|
||||
def prefer_int(input_data: str) -> typing.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
|
||||
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
||||
available_boss_locations: typing.Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
||||
Bosses.boss_location_table}
|
||||
|
||||
boss_shuffle_options = {None: 'none',
|
||||
'none': 'none',
|
||||
'basic': 'basic',
|
||||
'full': 'full',
|
||||
'chaos': 'chaos',
|
||||
'singularity': 'singularity'
|
||||
}
|
||||
|
||||
goals = {
|
||||
'ganon': 'ganon',
|
||||
'crystals': 'crystals',
|
||||
'bosses': 'bosses',
|
||||
'pedestal': 'pedestal',
|
||||
'ganon_pedestal': 'ganonpedestal',
|
||||
'triforce_hunt': 'triforcehunt',
|
||||
'local_triforce_hunt': 'localtriforcehunt',
|
||||
'ganon_triforce_hunt': 'ganontriforcehunt',
|
||||
'local_ganon_triforce_hunt': 'localganontriforcehunt',
|
||||
'ice_rod_hunt': 'icerodhunt',
|
||||
}
|
||||
|
||||
|
||||
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
|
||||
"""Roll a percentage chance.
|
||||
percentage is expected to be in range [0, 100]"""
|
||||
return random.random() < (float(percentage) / 100)
|
||||
|
||||
|
||||
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
|
||||
logging.debug(f'Applying {new_weights}')
|
||||
new_options = set(new_weights) - set(weights)
|
||||
weights.update(new_weights)
|
||||
if new_options:
|
||||
for new_option in new_options:
|
||||
logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
|
||||
f'overwrite a root option. '
|
||||
f'This is probably in error.')
|
||||
return weights
|
||||
|
||||
|
||||
def roll_linked_options(weights: dict) -> dict:
|
||||
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.")
|
||||
try:
|
||||
if roll_percentage(option_set["percentage"]):
|
||||
logging.debug(f"Linked option {option_set['name']} triggered.")
|
||||
new_options = option_set["options"]
|
||||
for category_name, category_options in new_options.items():
|
||||
currently_targeted_weights = weights
|
||||
if category_name:
|
||||
currently_targeted_weights = currently_targeted_weights[category_name]
|
||||
update_weights(currently_targeted_weights, category_options, "Linked", option_set["name"])
|
||||
else:
|
||||
logging.debug(f"linked option {option_set['name']} skipped.")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Linked option {option_set['name']} is destroyed. "
|
||||
f"Please fix your linked option.") from e
|
||||
return weights
|
||||
|
||||
|
||||
def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||
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:
|
||||
currently_targeted_weights = weights
|
||||
category = option_set.get("option_category", None)
|
||||
if category:
|
||||
currently_targeted_weights = currently_targeted_weights[category]
|
||||
key = get_choice("option_name", option_set)
|
||||
if key not in currently_targeted_weights:
|
||||
logging.warning(f'Specified option name {option_set["option_name"]} did not '
|
||||
f'match with a root option. '
|
||||
f'This is probably in error.')
|
||||
trigger_result = get_choice("option_result", option_set)
|
||||
result = get_choice(key, currently_targeted_weights)
|
||||
currently_targeted_weights[key] = result
|
||||
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
|
||||
for category_name, category_options in option_set["options"].items():
|
||||
currently_targeted_weights = weights
|
||||
if category_name:
|
||||
currently_targeted_weights = currently_targeted_weights[category_name]
|
||||
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Your trigger number {i + 1} is destroyed. "
|
||||
f"Please fix your triggers.") from e
|
||||
return weights
|
||||
|
||||
|
||||
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
|
||||
if boss_shuffle in boss_shuffle_options:
|
||||
return boss_shuffle_options[boss_shuffle]
|
||||
elif "bosses" in plando_options:
|
||||
options = boss_shuffle.lower().split(";")
|
||||
remainder_shuffle = "none" # vanilla
|
||||
bosses = []
|
||||
for boss in options:
|
||||
if boss in boss_shuffle_options:
|
||||
remainder_shuffle = boss_shuffle_options[boss]
|
||||
elif "-" in boss:
|
||||
loc, boss_name = boss.split("-")
|
||||
if boss_name not in available_boss_names:
|
||||
raise ValueError(f"Unknown Boss name {boss_name}")
|
||||
if loc not in available_boss_locations:
|
||||
raise ValueError(f"Unknown Boss Location {loc}")
|
||||
level = ''
|
||||
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
||||
# split off level
|
||||
loc = loc.split(" ")
|
||||
level = f" {loc[-1]}"
|
||||
loc = " ".join(loc[:-1])
|
||||
loc = loc.title().replace("Of", "of")
|
||||
if not Bosses.can_place_boss(boss_name.title(), loc, level):
|
||||
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
|
||||
bosses.append(boss)
|
||||
elif boss not in available_boss_names:
|
||||
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
|
||||
else:
|
||||
bosses.append(boss)
|
||||
return ";".join(bosses + [remainder_shuffle])
|
||||
else:
|
||||
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
|
||||
|
||||
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
|
||||
if option_key in game_weights:
|
||||
try:
|
||||
if not option.supports_weighting:
|
||||
player_option = option.from_any(game_weights[option_key])
|
||||
else:
|
||||
player_option = option.from_any(get_choice(option_key, game_weights))
|
||||
setattr(ret, option_key, player_option)
|
||||
except Exception as e:
|
||||
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
||||
else:
|
||||
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",))):
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
if "triggers" in weights:
|
||||
weights = roll_triggers(weights, weights["triggers"])
|
||||
|
||||
requirements = weights.get("requires", {})
|
||||
if requirements:
|
||||
version = requirements.get("version", __version__)
|
||||
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
|
||||
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.")
|
||||
|
||||
ret = argparse.Namespace()
|
||||
for option_key in Options.per_game_common_options:
|
||||
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)
|
||||
if ret.game not in weights:
|
||||
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
|
||||
|
||||
world_type = AutoWorldRegister.world_types[ret.game]
|
||||
game_weights = weights[ret.game]
|
||||
|
||||
if "triggers" in game_weights:
|
||||
weights = roll_triggers(weights, game_weights["triggers"])
|
||||
game_weights = weights[ret.game]
|
||||
|
||||
ret.name = get_choice('name', weights)
|
||||
for option_key, option in Options.common_options.items():
|
||||
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
|
||||
|
||||
if ret.game in AutoWorldRegister.world_types:
|
||||
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():
|
||||
# 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 "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:
|
||||
options = game_weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice("entrance", placement),
|
||||
get_choice("exit", placement),
|
||||
get_choice("direction", placement)
|
||||
))
|
||||
elif ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, game_weights, plando_options)
|
||||
else:
|
||||
raise Exception(f"Unsupported game {ret.game}")
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
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)}.")
|
||||
glitches_required = get_choice_legacy('glitches_required', weights)
|
||||
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
|
||||
logging.warning("Only NMG, OWG, HMG and No Logic supported")
|
||||
glitches_required = 'none'
|
||||
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
|
||||
'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
|
||||
glitches_required]
|
||||
|
||||
ret.dark_room_logic = get_choice_legacy("dark_room_logic", weights, "lamp")
|
||||
if not ret.dark_room_logic: # None/False
|
||||
ret.dark_room_logic = "none"
|
||||
if ret.dark_room_logic == "sconces":
|
||||
ret.dark_room_logic = "torches"
|
||||
if ret.dark_room_logic not in {"lamp", "torches", "none"}:
|
||||
raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
|
||||
|
||||
entrance_shuffle = get_choice_legacy('entrance_shuffle', weights, 'vanilla')
|
||||
if entrance_shuffle.startswith('none-'):
|
||||
ret.shuffle = 'vanilla'
|
||||
else:
|
||||
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
|
||||
|
||||
goal = get_choice_legacy('goals', weights, 'ganon')
|
||||
|
||||
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')
|
||||
|
||||
ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice_legacy('triforce_pieces_required', weights, 20))
|
||||
|
||||
# sum a percentage to required
|
||||
if extra_pieces == 'percentage':
|
||||
percentage = max(100, float(get_choice_legacy('triforce_pieces_percentage', weights, 150))) / 100
|
||||
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
|
||||
# vanilla mode (specify how many pieces are)
|
||||
elif extra_pieces == 'available':
|
||||
ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any(
|
||||
get_choice_legacy('triforce_pieces_available', weights, 30))
|
||||
# required pieces + fixed extra
|
||||
elif extra_pieces == 'extra':
|
||||
extra_pieces = max(0, int(get_choice_legacy('triforce_pieces_extra', weights, 10)))
|
||||
ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
|
||||
|
||||
# change minimum to required pieces to avoid problems
|
||||
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
|
||||
|
||||
ret.shop_shuffle = get_choice_legacy('shop_shuffle', weights, '')
|
||||
if not ret.shop_shuffle:
|
||||
ret.shop_shuffle = ''
|
||||
|
||||
ret.mode = get_choice_legacy("mode", weights)
|
||||
|
||||
ret.difficulty = get_choice_legacy('item_pool', weights)
|
||||
|
||||
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
||||
|
||||
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
|
||||
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
|
||||
|
||||
ret.enemy_damage = {None: 'default',
|
||||
'default': 'default',
|
||||
'shuffled': 'shuffled',
|
||||
'random': 'chaos', # to be removed
|
||||
'chaos': 'chaos',
|
||||
}[get_choice_legacy('enemy_damage', weights)]
|
||||
|
||||
ret.enemy_health = get_choice_legacy('enemy_health', weights)
|
||||
|
||||
ret.timer = {'none': False,
|
||||
None: False,
|
||||
False: False,
|
||||
'timed': 'timed',
|
||||
'timed_ohko': 'timed-ohko',
|
||||
'ohko': 'ohko',
|
||||
'timed_countdown': 'timed-countdown',
|
||||
'display': 'display'}[get_choice_legacy('timer', weights, False)]
|
||||
|
||||
ret.countdown_start_time = int(get_choice_legacy('countdown_start_time', weights, 10))
|
||||
ret.red_clock_time = int(get_choice_legacy('red_clock_time', weights, -2))
|
||||
ret.blue_clock_time = int(get_choice_legacy('blue_clock_time', weights, 2))
|
||||
ret.green_clock_time = int(get_choice_legacy('green_clock_time', weights, 4))
|
||||
|
||||
ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default')
|
||||
|
||||
ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g")
|
||||
|
||||
ret.required_medallions = [get_choice_legacy("misery_mire_medallion", weights, "random"),
|
||||
get_choice_legacy("turtle_rock_medallion", weights, "random")]
|
||||
|
||||
for index, medallion in enumerate(ret.required_medallions):
|
||||
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
|
||||
.get(medallion.lower(), None)
|
||||
if not ret.required_medallions[index]:
|
||||
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||
|
||||
ret.plando_texts = {}
|
||||
if "texts" in plando_options:
|
||||
tt = TextTable()
|
||||
tt.removeUnwantedText()
|
||||
options = weights.get("plando_texts", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
at = str(get_choice_legacy("at", placement))
|
||||
if at not in tt:
|
||||
raise Exception(f"No text target \"{at}\" found.")
|
||||
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
||||
|
||||
ret.plando_connections = []
|
||||
if "connections" in plando_options:
|
||||
options = weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice_legacy("entrance", placement),
|
||||
get_choice_legacy("exit", placement),
|
||||
get_choice_legacy("direction", placement, "both")
|
||||
))
|
||||
|
||||
ret.sprite_pool = weights.get('sprite_pool', [])
|
||||
ret.sprite = get_choice_legacy('sprite', weights, "Link")
|
||||
if 'random_sprite_on_event' in weights:
|
||||
randomoneventweights = weights['random_sprite_on_event']
|
||||
if get_choice_legacy('enabled', randomoneventweights, False):
|
||||
ret.sprite = 'randomon'
|
||||
ret.sprite += '-hit' if get_choice_legacy('on_hit', randomoneventweights, True) else ''
|
||||
ret.sprite += '-enter' if get_choice_legacy('on_enter', randomoneventweights, False) else ''
|
||||
ret.sprite += '-exit' if get_choice_legacy('on_exit', randomoneventweights, False) else ''
|
||||
ret.sprite += '-slash' if get_choice_legacy('on_slash', randomoneventweights, False) else ''
|
||||
ret.sprite += '-item' if get_choice_legacy('on_item', randomoneventweights, False) else ''
|
||||
ret.sprite += '-bonk' if get_choice_legacy('on_bonk', randomoneventweights, False) else ''
|
||||
ret.sprite = 'randomonall' if get_choice_legacy('on_everything', randomoneventweights, False) else ret.sprite
|
||||
ret.sprite = 'randomonnone' if ret.sprite == 'randomon' else ret.sprite
|
||||
|
||||
if (not ret.sprite_pool or get_choice_legacy('use_weighted_sprite_pool', randomoneventweights, False)) \
|
||||
and 'sprite' in weights: # Use sprite as a weighted sprite pool, if a sprite pool is not already defined.
|
||||
for key, value in weights['sprite'].items():
|
||||
if key.startswith('random'):
|
||||
ret.sprite_pool += ['random'] * int(value)
|
||||
else:
|
||||
ret.sprite_pool += [key] * int(value)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import atexit
|
||||
confirmation = atexit.register(input, "Press enter to close.")
|
||||
main()
|
||||
# in case of error-free exit should not need confirmation
|
||||
atexit.unregister(confirmation)
|
||||
192
GuiUtils.py
192
GuiUtils.py
@@ -1,192 +0,0 @@
|
||||
import queue
|
||||
import threading
|
||||
import tkinter as tk
|
||||
|
||||
from Utils import local_path
|
||||
|
||||
def set_icon(window):
|
||||
logo = tk.PhotoImage(file=local_path('data', 'icon.png'))
|
||||
window.tk.call('wm', 'iconphoto', window._w, logo)
|
||||
|
||||
# Although tkinter is intended to be thread safe, there are many reports of issues
|
||||
# some which may be platform specific, or depend on if the TCL library was compiled without
|
||||
# multithreading support. Therefore I will assume it is not thread safe to avoid any possible problems
|
||||
class BackgroundTask(object):
|
||||
def __init__(self, window, code_to_run, *args):
|
||||
self.window = window
|
||||
self.queue = queue.Queue()
|
||||
self.running = True
|
||||
self.process_queue()
|
||||
self.task = threading.Thread(target=code_to_run, args=(self, *args))
|
||||
self.task.start()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
# safe to call from worker
|
||||
def queue_event(self, event):
|
||||
self.queue.put(event)
|
||||
|
||||
def process_queue(self):
|
||||
try:
|
||||
while True:
|
||||
if not self.running:
|
||||
return
|
||||
event = self.queue.get_nowait()
|
||||
event()
|
||||
if self.running:
|
||||
#if self is no longer running self.window may no longer be valid
|
||||
self.window.update_idletasks()
|
||||
except queue.Empty:
|
||||
pass
|
||||
if self.running:
|
||||
self.window.after(100, self.process_queue)
|
||||
|
||||
class BackgroundTaskProgress(BackgroundTask):
|
||||
def __init__(self, parent, code_to_run, title, *args):
|
||||
self.parent = parent
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window['padx'] = 5
|
||||
self.window['pady'] = 5
|
||||
|
||||
try:
|
||||
self.window.attributes("-toolwindow", 1)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
self.window.wm_title(title)
|
||||
self.label_var = tk.StringVar()
|
||||
self.label_var.set("")
|
||||
self.label = tk.Label(self.window, textvariable=self.label_var, width=50)
|
||||
self.label.pack()
|
||||
self.window.resizable(width=False, height=False)
|
||||
|
||||
set_icon(self.window)
|
||||
self.window.focus()
|
||||
super().__init__(self.window, code_to_run, *args)
|
||||
|
||||
#safe to call from worker thread
|
||||
def update_status(self, text):
|
||||
self.queue_event(lambda: self.label_var.set(text))
|
||||
|
||||
# only call this in an event callback
|
||||
def close_window(self):
|
||||
self.stop()
|
||||
self.window.destroy()
|
||||
|
||||
|
||||
|
||||
class ToolTips(object):
|
||||
# This class derived from wckToolTips which is available under the following license:
|
||||
|
||||
# Copyright (c) 1998-2007 by Secret Labs AB
|
||||
# Copyright (c) 1998-2007 by Fredrik Lundh
|
||||
#
|
||||
# By obtaining, using, and/or copying this software and/or its
|
||||
# associated documentation, you agree that you have read, understood,
|
||||
# and will comply with the following terms and conditions:
|
||||
#
|
||||
# Permission to use, copy, modify, and distribute this software and its
|
||||
# associated documentation for any purpose and without fee is hereby
|
||||
# granted, provided that the above copyright notice appears in all
|
||||
# copies, and that both that copyright notice and this permission notice
|
||||
# appear in supporting documentation, and that the name of Secret Labs
|
||||
# AB or the author not be used in advertising or publicity pertaining to
|
||||
# distribution of the software without specific, written prior
|
||||
# permission.
|
||||
#
|
||||
# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO
|
||||
# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
# FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR
|
||||
# ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
||||
# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
label = None
|
||||
window = None
|
||||
active = 0
|
||||
tag = None
|
||||
after_id = None
|
||||
|
||||
@classmethod
|
||||
def getcontroller(cls, widget):
|
||||
if cls.tag is None:
|
||||
|
||||
cls.tag = "ui_tooltip_%d" % id(cls)
|
||||
widget.bind_class(cls.tag, "<Enter>", cls.enter)
|
||||
widget.bind_class(cls.tag, "<Leave>", cls.leave)
|
||||
widget.bind_class(cls.tag, "<Motion>", cls.motion)
|
||||
widget.bind_class(cls.tag, "<Destroy>", cls.leave)
|
||||
|
||||
# pick suitable colors for tooltips
|
||||
try:
|
||||
cls.bg = "systeminfobackground"
|
||||
cls.fg = "systeminfotext"
|
||||
widget.winfo_rgb(cls.fg) # make sure system colors exist
|
||||
widget.winfo_rgb(cls.bg)
|
||||
except Exception:
|
||||
cls.bg = "#ffffe0"
|
||||
cls.fg = "black"
|
||||
|
||||
return cls.tag
|
||||
|
||||
@classmethod
|
||||
def register(cls, widget, text):
|
||||
widget.ui_tooltip_text = text
|
||||
tags = list(widget.bindtags())
|
||||
tags.append(cls.getcontroller(widget))
|
||||
widget.bindtags(tuple(tags))
|
||||
|
||||
@classmethod
|
||||
def unregister(cls, widget):
|
||||
tags = list(widget.bindtags())
|
||||
tags.remove(cls.getcontroller(widget))
|
||||
widget.bindtags(tuple(tags))
|
||||
|
||||
# event handlers
|
||||
@classmethod
|
||||
def enter(cls, event):
|
||||
widget = event.widget
|
||||
if not cls.label:
|
||||
# create and hide balloon help window
|
||||
cls.popup = tk.Toplevel(bg=cls.fg, bd=1)
|
||||
cls.popup.overrideredirect(1)
|
||||
cls.popup.withdraw()
|
||||
cls.label = tk.Label(
|
||||
cls.popup, fg=cls.fg, bg=cls.bg, bd=0, padx=2, justify=tk.LEFT
|
||||
)
|
||||
cls.label.pack()
|
||||
cls.active = 0
|
||||
cls.xy = event.x_root + 16, event.y_root + 10
|
||||
cls.event_xy = event.x, event.y
|
||||
cls.after_id = widget.after(200, cls.display, widget)
|
||||
|
||||
@classmethod
|
||||
def motion(cls, event):
|
||||
cls.xy = event.x_root + 16, event.y_root + 10
|
||||
cls.event_xy = event.x, event.y
|
||||
|
||||
@classmethod
|
||||
def display(cls, widget):
|
||||
if not cls.active:
|
||||
# display balloon help window
|
||||
text = widget.ui_tooltip_text
|
||||
if callable(text):
|
||||
text = text(widget, cls.event_xy)
|
||||
cls.label.config(text=text)
|
||||
cls.popup.deiconify()
|
||||
cls.popup.lift()
|
||||
cls.popup.geometry("+%d+%d" % cls.xy)
|
||||
cls.active = 1
|
||||
cls.after_id = None
|
||||
|
||||
@classmethod
|
||||
def leave(cls, event):
|
||||
widget = event.widget
|
||||
if cls.active:
|
||||
cls.popup.withdraw()
|
||||
cls.active = 0
|
||||
if cls.after_id:
|
||||
widget.after_cancel(cls.after_id)
|
||||
cls.after_id = None
|
||||
4
LICENSE
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
|
||||
|
||||
307
Launcher.py
Normal file
307
Launcher.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
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
|
||||
from shutil import which
|
||||
import shlex
|
||||
from enum import Enum, auto
|
||||
import logging
|
||||
|
||||
|
||||
is_linux = sys.platform.startswith('linux')
|
||||
is_macos = sys.platform == 'darwin'
|
||||
is_windows = sys.platform in ("win32", "cygwin", "msys")
|
||||
|
||||
|
||||
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():
|
||||
try:
|
||||
import tkinter
|
||||
import tkinter.filedialog
|
||||
except Exception as e:
|
||||
logging.error("Could not load tkinter, which is likely not installed. "
|
||||
"This attempt was made because Launcher.open_patch was used.")
|
||||
raise e
|
||||
else:
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
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 []
|
||||
filename = tkinter.filedialog.askopenfilename(filetypes=(('Patches', ' '.join(suffixes)),))
|
||||
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')),
|
||||
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'),
|
||||
# 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():
|
||||
if not sys.stdout:
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label # this kills stdout
|
||||
else:
|
||||
from kivy.app import App
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.floatlayout import FloatLayout as ContainerLayout
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.label import 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())
|
||||
1065
LttPAdjuster.py
1065
LttPAdjuster.py
File diff suppressed because it is too large
Load Diff
910
LttPClient.py
910
LttPClient.py
@@ -1,910 +0,0 @@
|
||||
import argparse
|
||||
import atexit
|
||||
import time
|
||||
import multiprocessing
|
||||
import os
|
||||
import subprocess
|
||||
import base64
|
||||
import shutil
|
||||
from json import loads, dumps
|
||||
|
||||
from Utils import get_item_name_from_id
|
||||
|
||||
exit_func = atexit.register(input, "Press enter to close.")
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
import colorama
|
||||
|
||||
from NetUtils import *
|
||||
|
||||
from worlds.alttp import Regions, Shops
|
||||
from worlds.alttp import Items
|
||||
import Utils
|
||||
from CommonClient import CommonContext, server_loop, logger, console_loop, ClientCommandProcessor
|
||||
|
||||
|
||||
from MultiServer import mark_raw
|
||||
|
||||
|
||||
class LttPCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_slow_mode(self, toggle: str = ""):
|
||||
"""Toggle slow mode, which limits how fast you send / receive items."""
|
||||
if toggle:
|
||||
self.ctx.slow_mode = toggle.lower() in {"1", "true", "on"}
|
||||
else:
|
||||
self.ctx.slow_mode = not self.ctx.slow_mode
|
||||
|
||||
self.output(f"Setting slow mode to {self.ctx.slow_mode}")
|
||||
|
||||
@mark_raw
|
||||
def _cmd_snes(self, snes_address: str = "") -> bool:
|
||||
"""Connect to a snes. Optionally include network address of a snes to connect to, otherwise show available devices"""
|
||||
self.ctx.snes_reconnect_address = None
|
||||
asyncio.create_task(snes_connect(self.ctx, snes_address if snes_address else self.ctx.snes_address))
|
||||
return True
|
||||
|
||||
def _cmd_snes_close(self) -> bool:
|
||||
"""Close connection to a currently connected snes"""
|
||||
self.ctx.snes_reconnect_address = None
|
||||
if self.ctx.snes_socket is not None and not self.ctx.snes_socket.closed:
|
||||
asyncio.create_task(self.ctx.snes_socket.close())
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
class Context(CommonContext):
|
||||
command_processor = LttPCommandProcessor
|
||||
def __init__(self, snes_address, server_address, password, found_items):
|
||||
super(Context, self).__init__(server_address, password, found_items)
|
||||
|
||||
# snes stuff
|
||||
self.snes_address = snes_address
|
||||
self.snes_socket = None
|
||||
self.snes_state = SNESState.SNES_DISCONNECTED
|
||||
self.snes_attached_device = None
|
||||
self.snes_reconnect_address = None
|
||||
self.snes_recv_queue = asyncio.Queue()
|
||||
self.snes_request_lock = asyncio.Lock()
|
||||
self.snes_write_buffer = []
|
||||
|
||||
self.awaiting_rom = False
|
||||
self.rom = None
|
||||
self.prev_rom = None
|
||||
|
||||
async def connection_closed(self):
|
||||
await super(Context, self).connection_closed()
|
||||
self.awaiting_rom = False
|
||||
|
||||
def event_invalid_slot(self):
|
||||
if self.snes_socket is not None and not self.snes_socket.closed:
|
||||
asyncio.create_task(self.snes_socket.close())
|
||||
raise Exception('Invalid ROM detected, '
|
||||
'please verify that you have loaded the correct rom and reconnect your snes (/snes)')
|
||||
|
||||
async def server_auth(self, password_requested):
|
||||
if password_requested and not self.password:
|
||||
await super(Context, self).server_auth(password_requested)
|
||||
if self.rom is None:
|
||||
self.awaiting_rom = True
|
||||
logger.info(
|
||||
'No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)')
|
||||
return
|
||||
self.awaiting_rom = False
|
||||
self.auth = self.rom
|
||||
auth = base64.b64encode(self.rom).decode()
|
||||
await self.send_msgs([{"cmd": 'Connect',
|
||||
'password': self.password, 'name': auth, 'version': Utils.version_tuple,
|
||||
'tags': get_tags(self),
|
||||
'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past"
|
||||
}])
|
||||
|
||||
|
||||
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
|
||||
|
||||
ROM_START = 0x000000
|
||||
WRAM_START = 0xF50000
|
||||
WRAM_SIZE = 0x20000
|
||||
SRAM_START = 0xE00000
|
||||
|
||||
ROMNAME_START = SRAM_START + 0x2000
|
||||
ROMNAME_SIZE = 0x15
|
||||
|
||||
INGAME_MODES = {0x07, 0x09, 0x0b}
|
||||
ENDGAME_MODES = {0x19, 0x1a}
|
||||
|
||||
SAVEDATA_START = WRAM_START + 0xF000
|
||||
SAVEDATA_SIZE = 0x500
|
||||
|
||||
RECV_PROGRESS_ADDR = SAVEDATA_START + 0x4D0 # 2 bytes
|
||||
RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
|
||||
RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
|
||||
ROOMID_ADDR = SAVEDATA_START + 0x4D4 # 2 bytes
|
||||
ROOMDATA_ADDR = SAVEDATA_START + 0x4D6 # 1 byte
|
||||
SCOUT_LOCATION_ADDR = SAVEDATA_START + 0x4D7 # 1 byte
|
||||
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
|
||||
|
||||
location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
|
||||
|
||||
location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
|
||||
"Blind's Hideout - Left": (0x11d, 0x20),
|
||||
"Blind's Hideout - Right": (0x11d, 0x40),
|
||||
"Blind's Hideout - Far Left": (0x11d, 0x80),
|
||||
"Blind's Hideout - Far Right": (0x11d, 0x100),
|
||||
'Secret Passage': (0x55, 0x10),
|
||||
'Waterfall Fairy - Left': (0x114, 0x10),
|
||||
'Waterfall Fairy - Right': (0x114, 0x20),
|
||||
"King's Tomb": (0x113, 0x10),
|
||||
'Floodgate Chest': (0x10b, 0x10),
|
||||
"Link's House": (0x104, 0x10),
|
||||
'Kakariko Tavern': (0x103, 0x10),
|
||||
'Chicken House': (0x108, 0x10),
|
||||
"Aginah's Cave": (0x10a, 0x10),
|
||||
"Sahasrahla's Hut - Left": (0x105, 0x10),
|
||||
"Sahasrahla's Hut - Middle": (0x105, 0x20),
|
||||
"Sahasrahla's Hut - Right": (0x105, 0x40),
|
||||
'Kakariko Well - Top': (0x2f, 0x10),
|
||||
'Kakariko Well - Left': (0x2f, 0x20),
|
||||
'Kakariko Well - Middle': (0x2f, 0x40),
|
||||
'Kakariko Well - Right': (0x2f, 0x80),
|
||||
'Kakariko Well - Bottom': (0x2f, 0x100),
|
||||
'Lost Woods Hideout': (0xe1, 0x200),
|
||||
'Lumberjack Tree': (0xe2, 0x200),
|
||||
'Cave 45': (0x11b, 0x400),
|
||||
'Graveyard Cave': (0x11b, 0x200),
|
||||
'Checkerboard Cave': (0x126, 0x200),
|
||||
'Mini Moldorm Cave - Far Left': (0x123, 0x10),
|
||||
'Mini Moldorm Cave - Left': (0x123, 0x20),
|
||||
'Mini Moldorm Cave - Right': (0x123, 0x40),
|
||||
'Mini Moldorm Cave - Far Right': (0x123, 0x80),
|
||||
'Mini Moldorm Cave - Generous Guy': (0x123, 0x400),
|
||||
'Ice Rod Cave': (0x120, 0x10),
|
||||
'Bonk Rock Cave': (0x124, 0x10),
|
||||
'Desert Palace - Big Chest': (0x73, 0x10),
|
||||
'Desert Palace - Torch': (0x73, 0x400),
|
||||
'Desert Palace - Map Chest': (0x74, 0x10),
|
||||
'Desert Palace - Compass Chest': (0x85, 0x10),
|
||||
'Desert Palace - Big Key Chest': (0x75, 0x10),
|
||||
'Desert Palace - Desert Tiles 1 Pot Key': (0x63, 0x400),
|
||||
'Desert Palace - Beamos Hall Pot Key': (0x53, 0x400),
|
||||
'Desert Palace - Desert Tiles 2 Pot Key': (0x43, 0x400),
|
||||
'Desert Palace - Boss': (0x33, 0x800),
|
||||
'Eastern Palace - Compass Chest': (0xa8, 0x10),
|
||||
'Eastern Palace - Big Chest': (0xa9, 0x10),
|
||||
'Eastern Palace - Dark Square Pot Key': (0xba, 0x400),
|
||||
'Eastern Palace - Dark Eyegore Key Drop': (0x99, 0x400),
|
||||
'Eastern Palace - Cannonball Chest': (0xb9, 0x10),
|
||||
'Eastern Palace - Big Key Chest': (0xb8, 0x10),
|
||||
'Eastern Palace - Map Chest': (0xaa, 0x10),
|
||||
'Eastern Palace - Boss': (0xc8, 0x800),
|
||||
'Hyrule Castle - Boomerang Chest': (0x71, 0x10),
|
||||
'Hyrule Castle - Boomerang Guard Key Drop': (0x71, 0x400),
|
||||
'Hyrule Castle - Map Chest': (0x72, 0x10),
|
||||
'Hyrule Castle - Map Guard Key Drop': (0x72, 0x400),
|
||||
"Hyrule Castle - Zelda's Chest": (0x80, 0x10),
|
||||
'Hyrule Castle - Big Key Drop': (0x80, 0x400),
|
||||
'Sewers - Dark Cross': (0x32, 0x10),
|
||||
'Hyrule Castle - Key Rat Key Drop': (0x21, 0x400),
|
||||
'Sewers - Secret Room - Left': (0x11, 0x10),
|
||||
'Sewers - Secret Room - Middle': (0x11, 0x20),
|
||||
'Sewers - Secret Room - Right': (0x11, 0x40),
|
||||
'Sanctuary': (0x12, 0x10),
|
||||
'Castle Tower - Room 03': (0xe0, 0x10),
|
||||
'Castle Tower - Dark Maze': (0xd0, 0x10),
|
||||
'Castle Tower - Dark Archer Key Drop': (0xc0, 0x400),
|
||||
'Castle Tower - Circle of Pots Key Drop': (0xb0, 0x400),
|
||||
'Spectacle Rock Cave': (0xea, 0x400),
|
||||
'Paradox Cave Lower - Far Left': (0xef, 0x10),
|
||||
'Paradox Cave Lower - Left': (0xef, 0x20),
|
||||
'Paradox Cave Lower - Right': (0xef, 0x40),
|
||||
'Paradox Cave Lower - Far Right': (0xef, 0x80),
|
||||
'Paradox Cave Lower - Middle': (0xef, 0x100),
|
||||
'Paradox Cave Upper - Left': (0xff, 0x10),
|
||||
'Paradox Cave Upper - Right': (0xff, 0x20),
|
||||
'Spiral Cave': (0xfe, 0x10),
|
||||
'Tower of Hera - Basement Cage': (0x87, 0x400),
|
||||
'Tower of Hera - Map Chest': (0x77, 0x10),
|
||||
'Tower of Hera - Big Key Chest': (0x87, 0x10),
|
||||
'Tower of Hera - Compass Chest': (0x27, 0x20),
|
||||
'Tower of Hera - Big Chest': (0x27, 0x10),
|
||||
'Tower of Hera - Boss': (0x7, 0x800),
|
||||
'Hype Cave - Top': (0x11e, 0x10),
|
||||
'Hype Cave - Middle Right': (0x11e, 0x20),
|
||||
'Hype Cave - Middle Left': (0x11e, 0x40),
|
||||
'Hype Cave - Bottom': (0x11e, 0x80),
|
||||
'Hype Cave - Generous Guy': (0x11e, 0x400),
|
||||
'Peg Cave': (0x127, 0x400),
|
||||
'Pyramid Fairy - Left': (0x116, 0x10),
|
||||
'Pyramid Fairy - Right': (0x116, 0x20),
|
||||
'Brewery': (0x106, 0x10),
|
||||
'C-Shaped House': (0x11c, 0x10),
|
||||
'Chest Game': (0x106, 0x400),
|
||||
'Mire Shed - Left': (0x10d, 0x10),
|
||||
'Mire Shed - Right': (0x10d, 0x20),
|
||||
'Superbunny Cave - Top': (0xf8, 0x10),
|
||||
'Superbunny Cave - Bottom': (0xf8, 0x20),
|
||||
'Spike Cave': (0x117, 0x10),
|
||||
'Hookshot Cave - Top Right': (0x3c, 0x10),
|
||||
'Hookshot Cave - Top Left': (0x3c, 0x20),
|
||||
'Hookshot Cave - Bottom Right': (0x3c, 0x80),
|
||||
'Hookshot Cave - Bottom Left': (0x3c, 0x40),
|
||||
'Mimic Cave': (0x10c, 0x10),
|
||||
'Swamp Palace - Entrance': (0x28, 0x10),
|
||||
'Swamp Palace - Map Chest': (0x37, 0x10),
|
||||
'Swamp Palace - Pot Row Pot Key': (0x38, 0x400),
|
||||
'Swamp Palace - Trench 1 Pot Key': (0x37, 0x400),
|
||||
'Swamp Palace - Hookshot Pot Key': (0x36, 0x400),
|
||||
'Swamp Palace - Big Chest': (0x36, 0x10),
|
||||
'Swamp Palace - Compass Chest': (0x46, 0x10),
|
||||
'Swamp Palace - Trench 2 Pot Key': (0x35, 0x400),
|
||||
'Swamp Palace - Big Key Chest': (0x35, 0x10),
|
||||
'Swamp Palace - West Chest': (0x34, 0x10),
|
||||
'Swamp Palace - Flooded Room - Left': (0x76, 0x10),
|
||||
'Swamp Palace - Flooded Room - Right': (0x76, 0x20),
|
||||
'Swamp Palace - Waterfall Room': (0x66, 0x10),
|
||||
'Swamp Palace - Waterway Pot Key': (0x16, 0x400),
|
||||
'Swamp Palace - Boss': (0x6, 0x800),
|
||||
"Thieves' Town - Big Key Chest": (0xdb, 0x20),
|
||||
"Thieves' Town - Map Chest": (0xdb, 0x10),
|
||||
"Thieves' Town - Compass Chest": (0xdc, 0x10),
|
||||
"Thieves' Town - Ambush Chest": (0xcb, 0x10),
|
||||
"Thieves' Town - Hallway Pot Key": (0xbc, 0x400),
|
||||
"Thieves' Town - Spike Switch Pot Key": (0xab, 0x400),
|
||||
"Thieves' Town - Attic": (0x65, 0x10),
|
||||
"Thieves' Town - Big Chest": (0x44, 0x10),
|
||||
"Thieves' Town - Blind's Cell": (0x45, 0x10),
|
||||
"Thieves' Town - Boss": (0xac, 0x800),
|
||||
'Skull Woods - Compass Chest': (0x67, 0x10),
|
||||
'Skull Woods - Map Chest': (0x58, 0x20),
|
||||
'Skull Woods - Big Chest': (0x58, 0x10),
|
||||
'Skull Woods - Pot Prison': (0x57, 0x20),
|
||||
'Skull Woods - Pinball Room': (0x68, 0x10),
|
||||
'Skull Woods - Big Key Chest': (0x57, 0x10),
|
||||
'Skull Woods - West Lobby Pot Key': (0x56, 0x400),
|
||||
'Skull Woods - Bridge Room': (0x59, 0x10),
|
||||
'Skull Woods - Spike Corner Key Drop': (0x39, 0x400),
|
||||
'Skull Woods - Boss': (0x29, 0x800),
|
||||
'Ice Palace - Jelly Key Drop': (0x0e, 0x400),
|
||||
'Ice Palace - Compass Chest': (0x2e, 0x10),
|
||||
'Ice Palace - Conveyor Key Drop': (0x3e, 0x400),
|
||||
'Ice Palace - Freezor Chest': (0x7e, 0x10),
|
||||
'Ice Palace - Big Chest': (0x9e, 0x10),
|
||||
'Ice Palace - Iced T Room': (0xae, 0x10),
|
||||
'Ice Palace - Many Pots Pot Key': (0x9f, 0x400),
|
||||
'Ice Palace - Spike Room': (0x5f, 0x10),
|
||||
'Ice Palace - Big Key Chest': (0x1f, 0x10),
|
||||
'Ice Palace - Hammer Block Key Drop': (0x3f, 0x400),
|
||||
'Ice Palace - Map Chest': (0x3f, 0x10),
|
||||
'Ice Palace - Boss': (0xde, 0x800),
|
||||
'Misery Mire - Big Chest': (0xc3, 0x10),
|
||||
'Misery Mire - Map Chest': (0xc3, 0x20),
|
||||
'Misery Mire - Main Lobby': (0xc2, 0x10),
|
||||
'Misery Mire - Bridge Chest': (0xa2, 0x10),
|
||||
'Misery Mire - Spikes Pot Key': (0xb3, 0x400),
|
||||
'Misery Mire - Spike Chest': (0xb3, 0x10),
|
||||
'Misery Mire - Fishbone Pot Key': (0xa1, 0x400),
|
||||
'Misery Mire - Conveyor Crystal Key Drop': (0xc1, 0x400),
|
||||
'Misery Mire - Compass Chest': (0xc1, 0x10),
|
||||
'Misery Mire - Big Key Chest': (0xd1, 0x10),
|
||||
'Misery Mire - Boss': (0x90, 0x800),
|
||||
'Turtle Rock - Compass Chest': (0xd6, 0x10),
|
||||
'Turtle Rock - Roller Room - Left': (0xb7, 0x10),
|
||||
'Turtle Rock - Roller Room - Right': (0xb7, 0x20),
|
||||
'Turtle Rock - Pokey 1 Key Drop': (0xb6, 0x400),
|
||||
'Turtle Rock - Chain Chomps': (0xb6, 0x10),
|
||||
'Turtle Rock - Pokey 2 Key Drop': (0x13, 0x400),
|
||||
'Turtle Rock - Big Key Chest': (0x14, 0x10),
|
||||
'Turtle Rock - Big Chest': (0x24, 0x10),
|
||||
'Turtle Rock - Crystaroller Room': (0x4, 0x10),
|
||||
'Turtle Rock - Eye Bridge - Bottom Left': (0xd5, 0x80),
|
||||
'Turtle Rock - Eye Bridge - Bottom Right': (0xd5, 0x40),
|
||||
'Turtle Rock - Eye Bridge - Top Left': (0xd5, 0x20),
|
||||
'Turtle Rock - Eye Bridge - Top Right': (0xd5, 0x10),
|
||||
'Turtle Rock - Boss': (0xa4, 0x800),
|
||||
'Palace of Darkness - Shooter Room': (0x9, 0x10),
|
||||
'Palace of Darkness - The Arena - Bridge': (0x2a, 0x20),
|
||||
'Palace of Darkness - Stalfos Basement': (0xa, 0x10),
|
||||
'Palace of Darkness - Big Key Chest': (0x3a, 0x10),
|
||||
'Palace of Darkness - The Arena - Ledge': (0x2a, 0x10),
|
||||
'Palace of Darkness - Map Chest': (0x2b, 0x10),
|
||||
'Palace of Darkness - Compass Chest': (0x1a, 0x20),
|
||||
'Palace of Darkness - Dark Basement - Left': (0x6a, 0x10),
|
||||
'Palace of Darkness - Dark Basement - Right': (0x6a, 0x20),
|
||||
'Palace of Darkness - Dark Maze - Top': (0x19, 0x10),
|
||||
'Palace of Darkness - Dark Maze - Bottom': (0x19, 0x20),
|
||||
'Palace of Darkness - Big Chest': (0x1a, 0x10),
|
||||
'Palace of Darkness - Harmless Hellway': (0x1a, 0x40),
|
||||
'Palace of Darkness - Boss': (0x5a, 0x800),
|
||||
'Ganons Tower - Conveyor Cross Pot Key': (0x8b, 0x400),
|
||||
"Ganons Tower - Bob's Torch": (0x8c, 0x400),
|
||||
'Ganons Tower - Hope Room - Left': (0x8c, 0x20),
|
||||
'Ganons Tower - Hope Room - Right': (0x8c, 0x40),
|
||||
'Ganons Tower - Tile Room': (0x8d, 0x10),
|
||||
'Ganons Tower - Compass Room - Top Left': (0x9d, 0x10),
|
||||
'Ganons Tower - Compass Room - Top Right': (0x9d, 0x20),
|
||||
'Ganons Tower - Compass Room - Bottom Left': (0x9d, 0x40),
|
||||
'Ganons Tower - Compass Room - Bottom Right': (0x9d, 0x80),
|
||||
'Ganons Tower - Conveyor Star Pits Pot Key': (0x7b, 0x400),
|
||||
'Ganons Tower - DMs Room - Top Left': (0x7b, 0x10),
|
||||
'Ganons Tower - DMs Room - Top Right': (0x7b, 0x20),
|
||||
'Ganons Tower - DMs Room - Bottom Left': (0x7b, 0x40),
|
||||
'Ganons Tower - DMs Room - Bottom Right': (0x7b, 0x80),
|
||||
'Ganons Tower - Map Chest': (0x8b, 0x10),
|
||||
'Ganons Tower - Double Switch Pot Key': (0x9b, 0x400),
|
||||
'Ganons Tower - Firesnake Room': (0x7d, 0x10),
|
||||
'Ganons Tower - Randomizer Room - Top Left': (0x7c, 0x10),
|
||||
'Ganons Tower - Randomizer Room - Top Right': (0x7c, 0x20),
|
||||
'Ganons Tower - Randomizer Room - Bottom Left': (0x7c, 0x40),
|
||||
'Ganons Tower - Randomizer Room - Bottom Right': (0x7c, 0x80),
|
||||
"Ganons Tower - Bob's Chest": (0x8c, 0x80),
|
||||
'Ganons Tower - Big Chest': (0x8c, 0x10),
|
||||
'Ganons Tower - Big Key Room - Left': (0x1c, 0x20),
|
||||
'Ganons Tower - Big Key Room - Right': (0x1c, 0x40),
|
||||
'Ganons Tower - Big Key Chest': (0x1c, 0x10),
|
||||
'Ganons Tower - Mini Helmasaur Room - Left': (0x3d, 0x10),
|
||||
'Ganons Tower - Mini Helmasaur Room - Right': (0x3d, 0x20),
|
||||
'Ganons Tower - Mini Helmasaur Key Drop': (0x3d, 0x400),
|
||||
'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40),
|
||||
'Ganons Tower - Validation Chest': (0x4d, 0x10)}
|
||||
|
||||
location_table_uw_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_uw.items()}
|
||||
|
||||
location_table_npc = {'Mushroom': 0x1000,
|
||||
'King Zora': 0x2,
|
||||
'Sahasrahla': 0x10,
|
||||
'Blacksmith': 0x400,
|
||||
'Magic Bat': 0x8000,
|
||||
'Sick Kid': 0x4,
|
||||
'Library': 0x80,
|
||||
'Potion Shop': 0x2000,
|
||||
'Old Man': 0x1,
|
||||
'Ether Tablet': 0x100,
|
||||
'Catfish': 0x20,
|
||||
'Stumpy': 0x8,
|
||||
'Bombos Tablet': 0x200}
|
||||
|
||||
location_table_npc_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_npc.items()}
|
||||
|
||||
location_table_ow = {'Flute Spot': 0x2a,
|
||||
'Sunken Treasure': 0x3b,
|
||||
"Zora's Ledge": 0x81,
|
||||
'Lake Hylia Island': 0x35,
|
||||
'Maze Race': 0x28,
|
||||
'Desert Ledge': 0x30,
|
||||
'Master Sword Pedestal': 0x80,
|
||||
'Spectacle Rock': 0x3,
|
||||
'Pyramid': 0x5b,
|
||||
'Digging Game': 0x68,
|
||||
'Bumper Cave Ledge': 0x4a,
|
||||
'Floating Island': 0x5}
|
||||
|
||||
location_table_ow_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_ow.items()}
|
||||
|
||||
location_table_misc = {'Bottle Merchant': (0x3c9, 0x2),
|
||||
'Purple Chest': (0x3c9, 0x10),
|
||||
"Link's Uncle": (0x3c6, 0x1),
|
||||
'Hobo': (0x3c9, 0x1)}
|
||||
|
||||
location_table_misc_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_misc.items()}
|
||||
|
||||
class SNESState(enum.IntEnum):
|
||||
SNES_DISCONNECTED = 0
|
||||
SNES_CONNECTING = 1
|
||||
SNES_CONNECTED = 2
|
||||
SNES_ATTACHED = 3
|
||||
|
||||
|
||||
def launch_sni(ctx: Context):
|
||||
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):
|
||||
if file.startswith("sni.") and not file.endswith(".proto"):
|
||||
sni_path = os.path.join(sni_path, file)
|
||||
|
||||
if os.path.isfile(sni_path):
|
||||
logger.info(f"Attempting to start {sni_path}")
|
||||
import subprocess
|
||||
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
else:
|
||||
logger.info(
|
||||
f"Attempt to start SNI was aborted as path {sni_path} was not found, "
|
||||
f"please start it yourself if it is not running")
|
||||
|
||||
|
||||
async def _snes_connect(ctx: Context, address: str):
|
||||
address = f"ws://{address}" if "://" not in address else address
|
||||
|
||||
logger.info("Connecting to SNI at %s ..." % address)
|
||||
seen_problems = set()
|
||||
succesful = False
|
||||
while not succesful:
|
||||
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
|
||||
if problem not in seen_problems:
|
||||
seen_problems.add(problem)
|
||||
logger.error(f"Error connecting to SNI ({problem})")
|
||||
|
||||
if len(seen_problems) == 1:
|
||||
# this is the first problem. Let's try launching SNI if it isn't already running
|
||||
launch_sni(ctx)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
return snes_socket
|
||||
|
||||
|
||||
async def get_snes_devices(ctx: Context):
|
||||
socket = await _snes_connect(ctx, ctx.snes_address) # establish new connection to poll
|
||||
DeviceList_Request = {
|
||||
"Opcode": "DeviceList",
|
||||
"Space": "SNES"
|
||||
}
|
||||
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
|
||||
|
||||
if not devices:
|
||||
logger.info('No SNES device found. Please connect a SNES device to SNI.')
|
||||
while not devices:
|
||||
await asyncio.sleep(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 socket.close()
|
||||
return devices
|
||||
|
||||
|
||||
async def snes_connect(ctx: Context, address):
|
||||
global SNES_RECONNECT_DELAY
|
||||
if ctx.snes_socket is not None and ctx.snes_state == SNESState.SNES_CONNECTED:
|
||||
logger.error('Already connected to snes')
|
||||
return
|
||||
|
||||
recv_task = None
|
||||
ctx.snes_state = SNESState.SNES_CONNECTING
|
||||
socket = await _snes_connect(ctx, address)
|
||||
ctx.snes_socket = socket
|
||||
ctx.snes_state = SNESState.SNES_CONNECTED
|
||||
|
||||
try:
|
||||
devices = await get_snes_devices(ctx)
|
||||
|
||||
if len(devices) == 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]]
|
||||
else:
|
||||
await snes_disconnect(ctx)
|
||||
return
|
||||
|
||||
logger.info("Attaching to " + device)
|
||||
|
||||
Attach_Request = {
|
||||
"Opcode": "Attach",
|
||||
"Space": "SNES",
|
||||
"Operands": [device]
|
||||
}
|
||||
await ctx.snes_socket.send(dumps(Attach_Request))
|
||||
ctx.snes_state = SNESState.SNES_ATTACHED
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
if recv_task is not None:
|
||||
if not ctx.snes_socket.closed:
|
||||
await ctx.snes_socket.close()
|
||||
else:
|
||||
if ctx.snes_socket is not None:
|
||||
if not ctx.snes_socket.closed:
|
||||
await ctx.snes_socket.close()
|
||||
ctx.snes_socket = None
|
||||
ctx.snes_state = SNESState.SNES_DISCONNECTED
|
||||
if not ctx.snes_reconnect_address:
|
||||
logger.error("Error connecting to snes (%s)" % e)
|
||||
else:
|
||||
logger.error(f"Error connecting to snes, attempt again in {SNES_RECONNECT_DELAY}s")
|
||||
asyncio.create_task(snes_autoreconnect(ctx))
|
||||
SNES_RECONNECT_DELAY *= 2
|
||||
|
||||
|
||||
async def snes_disconnect(ctx: Context):
|
||||
if ctx.snes_socket:
|
||||
if not ctx.snes_socket.closed:
|
||||
await ctx.snes_socket.close()
|
||||
ctx.snes_socket = None
|
||||
|
||||
|
||||
async def snes_autoreconnect(ctx: Context):
|
||||
# unfortunately currently broken. See: https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1033
|
||||
# with prompt_toolkit.shortcuts.ProgressBar() as pb:
|
||||
# for _ in pb(range(100)):
|
||||
# await asyncio.sleep(RECONNECT_DELAY/100)
|
||||
|
||||
await asyncio.sleep(SNES_RECONNECT_DELAY)
|
||||
if ctx.snes_reconnect_address and ctx.snes_socket is None:
|
||||
await snes_connect(ctx, ctx.snes_reconnect_address)
|
||||
|
||||
|
||||
async def snes_recv_loop(ctx: Context):
|
||||
try:
|
||||
async for msg in ctx.snes_socket:
|
||||
ctx.snes_recv_queue.put_nowait(msg)
|
||||
logger.warning("Snes disconnected")
|
||||
except Exception as e:
|
||||
if not isinstance(e, websockets.WebSocketException):
|
||||
logger.exception(e)
|
||||
logger.error("Lost connection to the snes, type /snes to reconnect")
|
||||
finally:
|
||||
socket, ctx.snes_socket = ctx.snes_socket, None
|
||||
if socket is not None and not socket.closed:
|
||||
await socket.close()
|
||||
|
||||
ctx.snes_state = SNESState.SNES_DISCONNECTED
|
||||
ctx.snes_recv_queue = asyncio.Queue()
|
||||
ctx.hud_message_queue = []
|
||||
|
||||
ctx.rom = None
|
||||
|
||||
if ctx.snes_reconnect_address:
|
||||
logger.info(f"...reconnecting in {SNES_RECONNECT_DELAY}s")
|
||||
asyncio.create_task(snes_autoreconnect(ctx))
|
||||
|
||||
|
||||
async def snes_read(ctx: Context, address, size):
|
||||
try:
|
||||
await ctx.snes_request_lock.acquire()
|
||||
|
||||
if ctx.snes_state != SNESState.SNES_ATTACHED or ctx.snes_socket is None or not ctx.snes_socket.open or ctx.snes_socket.closed:
|
||||
return None
|
||||
|
||||
GetAddress_Request = {
|
||||
"Opcode": "GetAddress",
|
||||
"Space": "SNES",
|
||||
"Operands": [hex(address)[2:], hex(size)[2:]]
|
||||
}
|
||||
try:
|
||||
await ctx.snes_socket.send(dumps(GetAddress_Request))
|
||||
except websockets.ConnectionClosed:
|
||||
return None
|
||||
|
||||
data = bytes()
|
||||
while len(data) < size:
|
||||
try:
|
||||
data += await asyncio.wait_for(ctx.snes_recv_queue.get(), 5)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
|
||||
if len(data) != size:
|
||||
logger.error('Error reading %s, requested %d bytes, received %d' % (hex(address), size, len(data)))
|
||||
if len(data):
|
||||
logger.error(str(data))
|
||||
logger.warning('Communication Failure with SNI')
|
||||
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
|
||||
await ctx.snes_socket.close()
|
||||
return None
|
||||
|
||||
return data
|
||||
finally:
|
||||
ctx.snes_request_lock.release()
|
||||
|
||||
|
||||
async def snes_write(ctx: Context, write_list):
|
||||
try:
|
||||
await ctx.snes_request_lock.acquire()
|
||||
|
||||
if ctx.snes_state != SNESState.SNES_ATTACHED or ctx.snes_socket is None or \
|
||||
not ctx.snes_socket.open or ctx.snes_socket.closed:
|
||||
return False
|
||||
|
||||
PutAddress_Request = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
|
||||
try:
|
||||
for address, data in write_list:
|
||||
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
|
||||
if ctx.snes_socket is not None:
|
||||
await ctx.snes_socket.send(dumps(PutAddress_Request))
|
||||
await ctx.snes_socket.send(data)
|
||||
else:
|
||||
logger.warning(f"Could not send data to SNES: {data}")
|
||||
except websockets.ConnectionClosed:
|
||||
return False
|
||||
|
||||
return True
|
||||
finally:
|
||||
ctx.snes_request_lock.release()
|
||||
|
||||
|
||||
def snes_buffered_write(ctx: Context, address, data):
|
||||
if ctx.snes_write_buffer and (ctx.snes_write_buffer[-1][0] + len(ctx.snes_write_buffer[-1][1])) == address:
|
||||
# append to existing write command, bundling them
|
||||
ctx.snes_write_buffer[-1] = (ctx.snes_write_buffer[-1][0], ctx.snes_write_buffer[-1][1] + data)
|
||||
else:
|
||||
ctx.snes_write_buffer.append((address, data))
|
||||
|
||||
|
||||
async def snes_flush_writes(ctx: Context):
|
||||
if not ctx.snes_write_buffer:
|
||||
return
|
||||
|
||||
# swap buffers
|
||||
ctx.snes_write_buffer, writes = [], ctx.snes_write_buffer
|
||||
await snes_write(ctx, writes)
|
||||
|
||||
|
||||
# kept as function for easier wrapping by plugins
|
||||
def get_tags(ctx: Context):
|
||||
tags = ['AP']
|
||||
return tags
|
||||
|
||||
|
||||
async def track_locations(ctx: Context, roomid, roomdata):
|
||||
new_locations = []
|
||||
|
||||
def new_check(location_id):
|
||||
new_locations.append(location_id)
|
||||
ctx.locations_checked.add(location_id)
|
||||
location = ctx.location_name_getter(location_id)
|
||||
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)
|
||||
except Exception as e:
|
||||
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:
|
||||
new_check(location_id)
|
||||
except Exception as e:
|
||||
logger.exception(f"Exception: {e}")
|
||||
|
||||
uw_begin = 0x129
|
||||
ow_end = uw_end = 0
|
||||
uw_unchecked = {}
|
||||
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 uw_begin < uw_end:
|
||||
uw_data = await snes_read(ctx, SAVEDATA_START + (uw_begin * 2), (uw_end - uw_begin) * 2)
|
||||
if uw_data is not None:
|
||||
for location_id, (roomid, mask) in uw_unchecked.items():
|
||||
offset = (roomid - uw_begin) * 2
|
||||
roomdata = uw_data[offset] | (uw_data[offset + 1] << 8)
|
||||
if roomdata & mask != 0:
|
||||
new_check(location_id)
|
||||
|
||||
ow_begin = 0x82
|
||||
ow_unchecked = {}
|
||||
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 ow_begin < ow_end:
|
||||
ow_data = await snes_read(ctx, SAVEDATA_START + 0x280 + ow_begin, ow_end - ow_begin)
|
||||
if ow_data is not None:
|
||||
for location_id, screenid in ow_unchecked.items():
|
||||
if ow_data[screenid - ow_begin] & 0x40 != 0:
|
||||
new_check(location_id)
|
||||
|
||||
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 = 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 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:
|
||||
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 new_locations:
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
|
||||
|
||||
|
||||
async def game_watcher(ctx: Context):
|
||||
prev_game_timer = 0
|
||||
perf_counter = time.perf_counter()
|
||||
while not ctx.exit_event.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(ctx.watcher_event.wait(), 0.125)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
ctx.watcher_event.clear()
|
||||
|
||||
if not ctx.rom:
|
||||
ctx.finished_game = False
|
||||
rom = await snes_read(ctx, ROMNAME_START, ROMNAME_SIZE)
|
||||
if rom is None or rom == bytes([0] * ROMNAME_SIZE):
|
||||
continue
|
||||
|
||||
ctx.rom = rom
|
||||
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
|
||||
ctx.locations_checked = set()
|
||||
ctx.locations_scouted = set()
|
||||
ctx.prev_rom = ctx.rom
|
||||
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
|
||||
if ctx.auth and ctx.auth != ctx.rom:
|
||||
logger.warning("ROM change detected, please reconnect to the multiworld server")
|
||||
await ctx.disconnect()
|
||||
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
|
||||
gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1)
|
||||
game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4)
|
||||
if gamemode is None or gameend is None or game_timer is None or \
|
||||
(gamemode[0] not in INGAME_MODES and gamemode[0] not in ENDGAME_MODES):
|
||||
continue
|
||||
|
||||
delay = 7 if ctx.slow_mode else 2
|
||||
if gameend[0]:
|
||||
if not ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
|
||||
if time.perf_counter() - perf_counter < delay:
|
||||
continue
|
||||
else:
|
||||
perf_counter = time.perf_counter()
|
||||
else:
|
||||
game_timer = game_timer[0] | (game_timer[1] << 8) | (game_timer[2] << 16) | (game_timer[3] << 24)
|
||||
if abs(game_timer - prev_game_timer) < (delay * 60):
|
||||
continue
|
||||
else:
|
||||
prev_game_timer = game_timer
|
||||
|
||||
if gamemode in ENDGAME_MODES: # triforce room and credits
|
||||
continue
|
||||
|
||||
data = await snes_read(ctx, RECV_PROGRESS_ADDR, 8)
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
recv_index = data[0] | (data[1] << 8)
|
||||
recv_item = data[2]
|
||||
roomid = data[4] | (data[5] << 8)
|
||||
roomdata = data[6]
|
||||
scout_location = data[7]
|
||||
|
||||
if recv_index < len(ctx.items_received) and recv_item == 0:
|
||||
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)))
|
||||
|
||||
snes_buffered_write(ctx, RECV_PROGRESS_ADDR, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||
snes_buffered_write(ctx, RECV_ITEM_ADDR, bytes([item.item]))
|
||||
snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR, bytes([item.player if item.player != ctx.slot else 0]))
|
||||
if scout_location > 0 and scout_location in ctx.locations_info:
|
||||
snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR, bytes([scout_location]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR, bytes([ctx.locations_info[scout_location][0]]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR, bytes([ctx.locations_info[scout_location][1]]))
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = Utils.get_options()["lttp_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 main():
|
||||
multiprocessing.freeze_support()
|
||||
parser = argparse.ArgumentParser()
|
||||
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('--connect', default=None, help='Address of the multiworld host.')
|
||||
parser.add_argument('--password', default=None, help='Password of the multiworld host.')
|
||||
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
|
||||
parser.add_argument('--founditems', default=False, action='store_true',
|
||||
help='Show items found by other players for themselves.')
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(format='%(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
|
||||
if args.diff_file:
|
||||
import Patch
|
||||
logging.info("Patch file was supplied. Creating sfc rom..")
|
||||
meta, romfile = Patch.create_rom_file(args.diff_file)
|
||||
args.connect = meta["server"]
|
||||
logging.info(f"Wrote rom file to {romfile}")
|
||||
adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile)
|
||||
if adjusted:
|
||||
try:
|
||||
shutil.move(adjustedromfile, romfile)
|
||||
adjustedromfile = romfile
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
|
||||
|
||||
ctx = Context(args.snes, args.connect, args.password, args.founditems)
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
|
||||
if ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
asyncio.create_task(snes_connect(ctx, ctx.snes_address))
|
||||
watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
ctx.snes_reconnect_address = None
|
||||
|
||||
await watcher_task
|
||||
|
||||
if ctx.server is not None and not ctx.server.socket.closed:
|
||||
await ctx.server.socket.close()
|
||||
if ctx.server_task is not None:
|
||||
await ctx.server_task
|
||||
|
||||
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
|
||||
await ctx.snes_socket.close()
|
||||
|
||||
while ctx.input_requests > 0:
|
||||
ctx.input_queue.put_nowait(None)
|
||||
ctx.input_requests -= 1
|
||||
|
||||
await input_task
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
loop.close()
|
||||
colorama.deinit()
|
||||
atexit.unregister(exit_func)
|
||||
793
Main.py
793
Main.py
@@ -1,260 +1,220 @@
|
||||
from itertools import zip_longest
|
||||
import copy
|
||||
import collections
|
||||
from itertools import zip_longest, chain
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import zlib
|
||||
import concurrent.futures
|
||||
import pickle
|
||||
from typing import Dict, Tuple
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import Dict, Tuple, Optional, Set
|
||||
|
||||
from BaseClasses import MultiWorld, CollectionState, Region, Item
|
||||
from worlds.alttp.Items import ItemFactory, item_name_groups
|
||||
from worlds.alttp.Regions import create_regions, mark_light_world_regions, \
|
||||
lookup_vanilla_location_to_entrance
|
||||
from worlds.alttp.InvertedRegions import create_inverted_regions, mark_dark_world_regions
|
||||
from worlds.alttp.EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect
|
||||
from worlds.alttp.Rom import patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, LocalRom, get_hash_string
|
||||
from worlds.alttp.Rules import set_rules
|
||||
from worlds.alttp.Dungeons import create_dungeons, fill_dungeons, fill_dungeons_restrictive
|
||||
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 create_shops, ShopSlotFill, SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||
from worlds.alttp.ItemPool import generate_itempool, difficulties, fill_prizes
|
||||
from Utils import output_path, parse_player_names, get_options, __version__, version_tuple
|
||||
from worlds.generic.Rules import locality_rules
|
||||
from worlds import Games, lookup_any_item_name_to_id, AutoWorld
|
||||
import Patch
|
||||
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 import AutoWorld
|
||||
|
||||
seeddigits = 20
|
||||
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',
|
||||
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total"
|
||||
)
|
||||
|
||||
|
||||
def get_seed(seed=None):
|
||||
if seed is None:
|
||||
random.seed(None)
|
||||
return random.randint(0, pow(10, seeddigits) - 1)
|
||||
return seed
|
||||
|
||||
|
||||
def get_same_seed(world: MultiWorld, seed_def: tuple) -> str:
|
||||
seeds: Dict[tuple, str] = getattr(world, "__named_seeds", {})
|
||||
if seed_def in seeds:
|
||||
return seeds[seed_def]
|
||||
seeds[seed_def] = str(world.random.randint(0, 2 ** 64))
|
||||
world.__named_seeds = seeds
|
||||
return seeds[seed_def]
|
||||
|
||||
|
||||
def main(args, seed=None):
|
||||
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
|
||||
if not baked_server_options:
|
||||
baked_server_options = get_options()["server_options"]
|
||||
if args.outputpath:
|
||||
os.makedirs(args.outputpath, exist_ok=True)
|
||||
output_path.cached_path = args.outputpath
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
# initialize the world
|
||||
world = MultiWorld(args.multi)
|
||||
|
||||
logger = logging.getLogger('')
|
||||
world.seed = get_seed(seed)
|
||||
if args.race:
|
||||
world.secure()
|
||||
else:
|
||||
world.random.seed(world.seed)
|
||||
world.seed_name = str(args.outputname if args.outputname else world.seed)
|
||||
logger = logging.getLogger()
|
||||
world.set_seed(seed, args.race, str(args.outputname if args.outputname else world.seed))
|
||||
|
||||
world.shuffle = args.shuffle.copy()
|
||||
world.logic = args.logic.copy()
|
||||
world.mode = args.mode.copy()
|
||||
world.swordless = args.swordless.copy()
|
||||
world.difficulty = args.difficulty.copy()
|
||||
world.item_functionality = args.item_functionality.copy()
|
||||
world.timer = args.timer.copy()
|
||||
world.progressive = args.progressive.copy()
|
||||
world.goal = args.goal.copy()
|
||||
world.local_items = args.local_items.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.accessibility = args.accessibility.copy()
|
||||
world.retro = args.retro.copy()
|
||||
|
||||
world.hints = args.hints.copy()
|
||||
|
||||
world.mapshuffle = args.mapshuffle.copy()
|
||||
world.compassshuffle = args.compassshuffle.copy()
|
||||
world.keyshuffle = args.keyshuffle.copy()
|
||||
world.bigkeyshuffle = args.bigkeyshuffle.copy()
|
||||
world.open_pyramid = args.open_pyramid.copy()
|
||||
world.boss_shuffle = args.shufflebosses.copy()
|
||||
world.enemy_shuffle = args.enemy_shuffle.copy()
|
||||
world.enemy_health = args.enemy_health.copy()
|
||||
world.enemy_damage = args.enemy_damage.copy()
|
||||
world.killable_thieves = args.killable_thieves.copy()
|
||||
world.bush_shuffle = args.bush_shuffle.copy()
|
||||
world.tile_shuffle = args.tile_shuffle.copy()
|
||||
world.beemizer = args.beemizer.copy()
|
||||
world.beemizer_total_chance = args.beemizer_total_chance.copy()
|
||||
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
|
||||
world.timer = args.timer.copy()
|
||||
world.countdown_start_time = args.countdown_start_time.copy()
|
||||
world.red_clock_time = args.red_clock_time.copy()
|
||||
world.blue_clock_time = args.blue_clock_time.copy()
|
||||
world.green_clock_time = args.green_clock_time.copy()
|
||||
world.shufflepots = args.shufflepots.copy()
|
||||
world.progressive = args.progressive.copy()
|
||||
world.dungeon_counters = args.dungeon_counters.copy()
|
||||
world.glitch_boots = args.glitch_boots.copy()
|
||||
world.triforce_pieces_available = args.triforce_pieces_available.copy()
|
||||
world.triforce_pieces_required = args.triforce_pieces_required.copy()
|
||||
world.shop_shuffle = args.shop_shuffle.copy()
|
||||
world.progression_balancing = args.progression_balancing.copy()
|
||||
world.shuffle_prizes = args.shuffle_prizes.copy()
|
||||
world.sprite_pool = args.sprite_pool.copy()
|
||||
world.dark_room_logic = args.dark_room_logic.copy()
|
||||
world.plando_items = args.plando_items.copy()
|
||||
world.plando_texts = args.plando_texts.copy()
|
||||
world.plando_connections = args.plando_connections.copy()
|
||||
world.er_seeds = getattr(args, "er_seeds", {})
|
||||
world.restrict_dungeon_item_on_boss = args.restrict_dungeon_item_on_boss.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.slot_seeds = {player: random.Random(world.random.randint(0, 999999999)) for player in
|
||||
range(1, world.players + 1)}
|
||||
|
||||
for player in range(1, world.players + 1):
|
||||
world.er_seeds[player] = str(world.random.randint(0, 2 ** 64))
|
||||
|
||||
if "-" in world.shuffle[player]:
|
||||
shuffle, seed = world.shuffle[player].split("-", 1)
|
||||
world.shuffle[player] = shuffle
|
||||
if shuffle == "vanilla":
|
||||
world.er_seeds[player] = "vanilla"
|
||||
elif seed.startswith("group-") or args.race:
|
||||
# renamed from team to group to not confuse with existing team name use
|
||||
world.er_seeds[player] = get_same_seed(world, (
|
||||
shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
|
||||
else: # not a race or group seed, use set seed as is.
|
||||
world.er_seeds[player] = seed
|
||||
elif world.shuffle[player] == "vanilla":
|
||||
world.er_seeds[player] = "vanilla"
|
||||
|
||||
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:")
|
||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||
numlength = 8
|
||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||
logger.info(f" {name:30} {cls}")
|
||||
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}}")
|
||||
|
||||
parsed_names = parse_player_names(args.names, world.players, args.teams)
|
||||
world.teams = len(parsed_names)
|
||||
for i, team in enumerate(parsed_names, 1):
|
||||
if world.players > 1:
|
||||
logger.info('%s%s', 'Team%d: ' % i if world.teams > 1 else 'Players: ', ', '.join(team))
|
||||
for player, name in enumerate(team, 1):
|
||||
world.player_names[player].append(name)
|
||||
AutoWorld.call_all(world, "generate_early")
|
||||
|
||||
logger.info('')
|
||||
for player in world.alttp_player_ids:
|
||||
world.difficulty_requirements[player] = difficulties[world.difficulty[player]]
|
||||
|
||||
for player in world.player_ids:
|
||||
for item_name in args.startinventory[player]:
|
||||
item = Item(item_name, True, lookup_any_item_name_to_id[item_name], player)
|
||||
item.game = world.game[player]
|
||||
world.push_precollected(item)
|
||||
for item_name, count in world.start_inventory[player].value.items():
|
||||
for _ in range(count):
|
||||
world.push_precollected(world.create_item(item_name, player))
|
||||
|
||||
for player in world.player_ids:
|
||||
if player in world.get_game_players("A Link to the Past"):
|
||||
# enforce pre-defined local items.
|
||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||
world.local_items[player].value.add('Triforce Piece')
|
||||
|
||||
# enforce pre-defined local items.
|
||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||
world.local_items[player].add('Triforce Piece')
|
||||
# Not possible to place pendants/crystals out side of boss prizes yet.
|
||||
world.non_local_items[player].value -= item_name_groups['Pendants']
|
||||
world.non_local_items[player].value -= item_name_groups['Crystals']
|
||||
|
||||
# items can't be both local and non-local, prefer local
|
||||
world.non_local_items[player] -= world.local_items[player]
|
||||
|
||||
# dungeon items can't be in non-local if the appropriate dungeon item shuffle setting is not set.
|
||||
if not world.mapshuffle[player]:
|
||||
world.non_local_items[player] -= item_name_groups['Maps']
|
||||
|
||||
if not world.compassshuffle[player]:
|
||||
world.non_local_items[player] -= item_name_groups['Compasses']
|
||||
|
||||
if not world.keyshuffle[player]:
|
||||
world.non_local_items[player] -= item_name_groups['Small Keys']
|
||||
|
||||
if not world.bigkeyshuffle[player]:
|
||||
world.non_local_items[player] -= item_name_groups['Big Keys']
|
||||
|
||||
# Not possible to place pendants/crystals out side of boss prizes yet.
|
||||
world.non_local_items[player] -= item_name_groups['Pendants']
|
||||
world.non_local_items[player] -= item_name_groups['Crystals']
|
||||
world.non_local_items[player].value -= world.local_items[player].value
|
||||
|
||||
logger.info('Creating World.')
|
||||
AutoWorld.call_all(world, "create_regions")
|
||||
|
||||
for player in world.alttp_player_ids:
|
||||
if world.open_pyramid[player] == 'goal':
|
||||
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt',
|
||||
'localganontriforcehunt', 'ganonpedestal'}
|
||||
elif world.open_pyramid[player] == 'auto':
|
||||
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt',
|
||||
'localganontriforcehunt', 'ganonpedestal'} and \
|
||||
(world.shuffle[player] in {'vanilla', 'dungeonssimple', 'dungeonsfull',
|
||||
'dungeonscrossed'} or not world.shuffle_ganon)
|
||||
else:
|
||||
world.open_pyramid[player] = {'on': True, 'off': False, 'yes': True, 'no': False}.get(
|
||||
world.open_pyramid[player], 'auto')
|
||||
|
||||
world.triforce_pieces_available[player] = max(world.triforce_pieces_available[player],
|
||||
world.triforce_pieces_required[player])
|
||||
|
||||
if world.mode[player] != 'inverted':
|
||||
create_regions(world, player)
|
||||
else:
|
||||
create_inverted_regions(world, player)
|
||||
create_shops(world, player)
|
||||
create_dungeons(world, player)
|
||||
|
||||
logger.info('Shuffling the World about.')
|
||||
|
||||
for player in world.alttp_player_ids:
|
||||
if world.logic[player] not in ["noglitches", "minorglitches"] and world.shuffle[player] in \
|
||||
{"vanilla", "dungeonssimple", "dungeonsfull", "simple", "restricted", "full"}:
|
||||
world.fix_fake_world[player] = False
|
||||
|
||||
# seeded entrance shuffle
|
||||
old_random = world.random
|
||||
world.random = random.Random(world.er_seeds[player])
|
||||
|
||||
if world.mode[player] != 'inverted':
|
||||
link_entrances(world, player)
|
||||
mark_light_world_regions(world, player)
|
||||
else:
|
||||
link_inverted_entrances(world, player)
|
||||
mark_dark_world_regions(world, player)
|
||||
|
||||
world.random = old_random
|
||||
plando_connect(world, player)
|
||||
|
||||
logger.info('Generating Item Pool.')
|
||||
|
||||
for player in world.alttp_player_ids:
|
||||
generate_itempool(world, player)
|
||||
logger.info('Creating Items.')
|
||||
AutoWorld.call_all(world, "create_items")
|
||||
|
||||
logger.info('Calculating Access Rules.')
|
||||
if world.players > 1:
|
||||
for player in world.player_ids:
|
||||
locality_rules(world, player)
|
||||
else:
|
||||
world.non_local_items[1].value = set()
|
||||
world.local_items[1].value = set()
|
||||
|
||||
AutoWorld.call_all(world, "set_rules")
|
||||
|
||||
for player in world.alttp_player_ids:
|
||||
set_rules(world, player)
|
||||
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]):
|
||||
advancement = set()
|
||||
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
|
||||
if item.advancement:
|
||||
advancement.add(item.name)
|
||||
|
||||
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, advancement
|
||||
|
||||
common_item_count, common_advancement_items = 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():
|
||||
advancement = item_name in common_advancement_items
|
||||
for _ in range(item_count):
|
||||
new_item = group["world"].create_item(item_name)
|
||||
new_item.advancement = advancement
|
||||
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:
|
||||
@@ -262,304 +222,212 @@ def main(args, seed=None):
|
||||
|
||||
distribute_planned(world)
|
||||
|
||||
logger.info('Placing Dungeon Prizes.')
|
||||
logger.info('Running Pre Main Fill.')
|
||||
|
||||
fill_prizes(world)
|
||||
AutoWorld.call_all(world, "pre_fill")
|
||||
|
||||
logger.info('Placing Dungeon Items.')
|
||||
|
||||
if world.algorithm in ['balanced', 'vt26'] or any(
|
||||
list(args.mapshuffle.values()) + list(args.compassshuffle.values()) +
|
||||
list(args.keyshuffle.values()) + list(args.bigkeyshuffle.values())):
|
||||
fill_dungeons_restrictive(world)
|
||||
else:
|
||||
fill_dungeons(world)
|
||||
|
||||
logger.info('Fill the world.')
|
||||
logger.info(f'Filling the world with {len(world.itempool)} items.')
|
||||
|
||||
if world.algorithm == 'flood':
|
||||
flood_items(world) # different algo, biased towards early game progress items
|
||||
elif world.algorithm == 'vt25':
|
||||
distribute_items_restrictive(world, False)
|
||||
elif world.algorithm == 'vt26':
|
||||
distribute_items_restrictive(world, True)
|
||||
elif world.algorithm == 'balanced':
|
||||
distribute_items_restrictive(world, True)
|
||||
distribute_items_restrictive(world)
|
||||
|
||||
logger.info("Filling Shop Slots")
|
||||
|
||||
ShopSlotFill(world)
|
||||
AutoWorld.call_all(world, 'post_fill')
|
||||
|
||||
if world.players > 1:
|
||||
balance_multiworld_progression(world)
|
||||
|
||||
logger.info('Generating output files.')
|
||||
logger.info(f'Beginning output...')
|
||||
outfilebase = 'AP_' + world.seed_name
|
||||
rom_names = []
|
||||
|
||||
def _gen_rom(team: int, player: int):
|
||||
use_enemizer = (world.boss_shuffle[player] != 'none' or world.enemy_shuffle[player]
|
||||
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
|
||||
or world.shufflepots[player] or world.bush_shuffle[player]
|
||||
or world.killable_thieves[player])
|
||||
output = tempfile.TemporaryDirectory()
|
||||
with output as temp_dir:
|
||||
with concurrent.futures.ThreadPoolExecutor(world.players + 2) as pool:
|
||||
check_accessibility_task = pool.submit(world.fulfills_accessibility)
|
||||
|
||||
rom = LocalRom(args.rom)
|
||||
output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
|
||||
for player in world.player_ids:
|
||||
# skip starting a thread for methods that say "pass".
|
||||
if AutoWorld.World.generate_output.__code__ is not world.worlds[player].generate_output.__code__:
|
||||
output_file_futures.append(
|
||||
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
||||
|
||||
patch_rom(world, rom, player, team, use_enemizer)
|
||||
def get_entrance_to_region(region: Region):
|
||||
for entrance in region.entrances:
|
||||
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
|
||||
return entrance
|
||||
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
|
||||
return get_entrance_to_region(entrance.parent_region)
|
||||
|
||||
if use_enemizer:
|
||||
patch_enemizer(world, team, player, rom, args.enemizercli)
|
||||
# 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]}
|
||||
|
||||
if args.race:
|
||||
patch_race_rom(rom, world, player)
|
||||
for region in world.regions:
|
||||
if region.player in er_hint_data and region.locations:
|
||||
main_entrance = get_entrance_to_region(region)
|
||||
for location in region.locations:
|
||||
if type(location.address) == int: # skips events and crystals
|
||||
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
|
||||
er_hint_data[region.player][location.address] = main_entrance.name
|
||||
|
||||
world.spoiler.hashes[(player, team)] = get_hash_string(rom.hash)
|
||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||
for player in range(1, world.players + 1)}
|
||||
|
||||
palettes_options = {}
|
||||
palettes_options['dungeon'] = args.uw_palettes[player]
|
||||
palettes_options['overworld'] = args.ow_palettes[player]
|
||||
palettes_options['hud'] = args.hud_palettes[player]
|
||||
palettes_options['sword'] = args.sword_palettes[player]
|
||||
palettes_options['shield'] = args.shield_palettes[player]
|
||||
palettes_options['link'] = args.link_palettes[player]
|
||||
for player in range(1, world.players + 1):
|
||||
checks_in_area[player]["Total"] = 0
|
||||
|
||||
apply_rom_settings(rom, args.heartbeep[player], args.heartcolor[player], args.quickswap[player],
|
||||
args.fastmenu[player], args.disablemusic[player], args.sprite[player],
|
||||
palettes_options, world, player, True,
|
||||
reduceflashing=args.reduceflashing[player] or args.race,
|
||||
triforcehud=args.triforcehud[player])
|
||||
for location in world.get_filled_locations():
|
||||
if type(location.address) is int:
|
||||
main_entrance = get_entrance_to_region(location.parent_region)
|
||||
if location.game != "A Link to the Past":
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif location.parent_region.dungeon:
|
||||
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
||||
'Inverted Ganons Tower': 'Ganons Tower'} \
|
||||
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||
checks_in_area[location.player][dungeonname].append(location.address)
|
||||
elif location.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif location.parent_region.type == RegionType.DarkWorld:
|
||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||
checks_in_area[location.player]["Total"] += 1
|
||||
|
||||
mcsb_name = ''
|
||||
if all([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
|
||||
world.bigkeyshuffle[player]]):
|
||||
mcsb_name = '-keysanity'
|
||||
elif [world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
|
||||
world.bigkeyshuffle[player]].count(True) == 1:
|
||||
mcsb_name = '-mapshuffle' if world.mapshuffle[player] else \
|
||||
'-compassshuffle' if world.compassshuffle[player] else \
|
||||
'-universal_keys' if world.keyshuffle[player] == "universal" else \
|
||||
'-keyshuffle' if world.keyshuffle[player] else '-bigkeyshuffle'
|
||||
elif any([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
|
||||
world.bigkeyshuffle[player]]):
|
||||
mcsb_name = '-%s%s%s%sshuffle' % (
|
||||
'M' if world.mapshuffle[player] else '', 'C' if world.compassshuffle[player] else '',
|
||||
'U' if world.keyshuffle[player] == "universal" else 'S' if world.keyshuffle[player] else '',
|
||||
'B' if world.bigkeyshuffle[player] else '')
|
||||
oldmancaves = []
|
||||
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]]:
|
||||
item = world.create_item(
|
||||
region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
|
||||
region.player)
|
||||
player = region.player
|
||||
location_id = SHOP_ID_START + total_shop_slots + index
|
||||
|
||||
outfilepname = f'_P{player}'
|
||||
outfilepname += f"_{world.player_names[player][team].replace(' ', '_')}" \
|
||||
if world.player_names[player][team] != 'Player%d' % player else ''
|
||||
outfilestuffs = {
|
||||
"logic": world.logic[player], # 0
|
||||
"difficulty": world.difficulty[player], # 1
|
||||
"item_functionality": world.item_functionality[player], # 2
|
||||
"mode": world.mode[player], # 3
|
||||
"goal": world.goal[player], # 4
|
||||
"timer": str(world.timer[player]), # 5
|
||||
"shuffle": world.shuffle[player], # 6
|
||||
"algorithm": world.algorithm, # 7
|
||||
"mscb": mcsb_name, # 8
|
||||
"retro": world.retro[player], # 9
|
||||
"progressive": world.progressive, # A
|
||||
"hints": 'True' if world.hints[player] else 'False' # B
|
||||
}
|
||||
# 0 1 2 3 4 5 6 7 8 9 A B
|
||||
outfilesuffix = ('_%s_%s-%s-%s-%s%s_%s-%s%s%s%s%s' % (
|
||||
# 0 1 2 3 4 5 6 7 8 9 A B C
|
||||
# _noglitches_normal-normal-open-ganon-ohko_simple-balanced-keysanity-retro-prog_random-nohints
|
||||
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity-retro
|
||||
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -prog_random
|
||||
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -nohints
|
||||
outfilestuffs["logic"], # 0
|
||||
main_entrance = get_entrance_to_region(region)
|
||||
if main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[player]["Light World"].append(location_id)
|
||||
else:
|
||||
checks_in_area[player]["Dark World"].append(location_id)
|
||||
checks_in_area[player]["Total"] += 1
|
||||
|
||||
outfilestuffs["difficulty"], # 1
|
||||
outfilestuffs["item_functionality"], # 2
|
||||
outfilestuffs["mode"], # 3
|
||||
outfilestuffs["goal"], # 4
|
||||
"" if outfilestuffs["timer"] in ['False', 'none', 'display'] else "-" + outfilestuffs["timer"], # 5
|
||||
er_hint_data[player][location_id] = main_entrance.name
|
||||
oldmancaves.append(((location_id, player), (item.code, player)))
|
||||
|
||||
outfilestuffs["shuffle"], # 6
|
||||
outfilestuffs["algorithm"], # 7
|
||||
outfilestuffs["mscb"], # 8
|
||||
FillDisabledShopSlots(world)
|
||||
|
||||
"-retro" if outfilestuffs["retro"] == "True" else "", # 9
|
||||
"-prog_" + outfilestuffs["progressive"] if outfilestuffs["progressive"] in ['off', 'random'] else "", # A
|
||||
"-nohints" if not outfilestuffs["hints"] == "True" else "") # B
|
||||
) if not args.outputname else ''
|
||||
rompath = output_path(f'{outfilebase}{outfilepname}{outfilesuffix}.sfc')
|
||||
rom.write_to_file(rompath, hide_enemizer=True)
|
||||
if args.create_diff:
|
||||
Patch.create_patch_file(rompath, player=player, player_name=world.player_names[player][team])
|
||||
return player, team, bytes(rom.name)
|
||||
def write_multidata():
|
||||
import NetUtils
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
games = {}
|
||||
minimum_versions = {"server": (0, 2, 4), "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()
|
||||
games[slot] = world.game[slot]
|
||||
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 + len(world.groups))}
|
||||
|
||||
pool = concurrent.futures.ThreadPoolExecutor()
|
||||
|
||||
check_accessibility_task = pool.submit(world.fulfills_accessibility)
|
||||
for slot in world.player_ids:
|
||||
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||
|
||||
rom_futures = []
|
||||
output_file_futures = []
|
||||
for team in range(world.teams):
|
||||
for player in world.alttp_player_ids:
|
||||
rom_futures.append(pool.submit(_gen_rom, team, player))
|
||||
for player in world.player_ids:
|
||||
output_file_futures.append(pool.submit(AutoWorld.call_single, world, "generate_output", player))
|
||||
|
||||
def get_entrance_to_region(region: Region):
|
||||
for entrance in region.entrances:
|
||||
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld):
|
||||
return entrance
|
||||
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
|
||||
return get_entrance_to_region(entrance.parent_region)
|
||||
|
||||
# collect ER hint info
|
||||
er_hint_data = {player: {} for player in range(1, world.players + 1) if
|
||||
world.shuffle[player] != "vanilla" or world.retro[player]}
|
||||
from worlds.alttp.Regions import RegionType
|
||||
for region in world.regions:
|
||||
if region.player in er_hint_data and region.locations:
|
||||
main_entrance = get_entrance_to_region(region)
|
||||
for location in region.locations:
|
||||
if type(location.address) == int: # skips events and crystals
|
||||
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
|
||||
er_hint_data[region.player][location.address] = main_entrance.name
|
||||
|
||||
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',
|
||||
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
|
||||
|
||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||
for player in range(1, world.players + 1)}
|
||||
|
||||
for player in range(1, world.players + 1):
|
||||
checks_in_area[player]["Total"] = 0
|
||||
|
||||
for location in [loc for loc in world.get_filled_locations() if type(loc.address) is int]:
|
||||
main_entrance = get_entrance_to_region(location.parent_region)
|
||||
if location.game != Games.LTTP:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif location.parent_region.dungeon:
|
||||
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
||||
'Inverted Ganons Tower': 'Ganons Tower'} \
|
||||
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||
checks_in_area[location.player][dungeonname].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||
checks_in_area[location.player]["Total"] += 1
|
||||
|
||||
oldmancaves = []
|
||||
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 range(1, world.players + 1) if
|
||||
world.retro[player]]:
|
||||
item = ItemFactory(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
|
||||
region.player)
|
||||
player = region.player
|
||||
location_id = SHOP_ID_START + total_shop_slots + index
|
||||
|
||||
main_entrance = get_entrance_to_region(region)
|
||||
if main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[player]["Light World"].append(location_id)
|
||||
else:
|
||||
checks_in_area[player]["Dark World"].append(location_id)
|
||||
checks_in_area[player]["Total"] += 1
|
||||
|
||||
er_hint_data[player][location_id] = main_entrance.name
|
||||
oldmancaves.append(((location_id, player), (item.code, player)))
|
||||
|
||||
FillDisabledShopSlots(world)
|
||||
|
||||
def write_multidata(roms, outputs):
|
||||
import base64
|
||||
import NetUtils
|
||||
for future in roms:
|
||||
rom_name = future.result()
|
||||
rom_names.append(rom_name)
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
minimum_versions = {"server": (0, 1, 1), "clients": client_versions}
|
||||
games = {}
|
||||
for slot in world.player_ids:
|
||||
client_versions[slot] = world.worlds[slot].get_required_client_version()
|
||||
games[slot] = world.game[slot]
|
||||
connect_names = {base64.b64encode(rom_name).decode(): (team, slot) for
|
||||
slot, team, rom_name in rom_names}
|
||||
precollected_items = {player: [] for player in range(1, world.players + 1)}
|
||||
for item in world.precollected_items:
|
||||
precollected_items[item.player].append(item.code)
|
||||
precollected_hints = {player: set() for player in range(1, world.players + 1)}
|
||||
# for now special case Factorio tech_tree_information
|
||||
sending_visible_players = set()
|
||||
for player in world.factorio_player_ids:
|
||||
if world.tech_tree_information[player].value == 2:
|
||||
sending_visible_players.add(player)
|
||||
|
||||
for i, team in enumerate(parsed_names):
|
||||
for player, name in enumerate(team, 1):
|
||||
if player not in world.alttp_player_ids:
|
||||
connect_names[name] = (i, player)
|
||||
if world.hk_player_ids:
|
||||
for slot in world.hk_player_ids:
|
||||
slot_data[slot] = AutoWorld.call_single(world, "fill_slot_data", slot)
|
||||
for slot in world.minecraft_player_ids:
|
||||
slot_data[slot] = AutoWorld.call_single(world, "fill_slot_data", slot)
|
||||
|
||||
locations_data: Dict[int, Dict[int, Tuple[int, int]]] = {player: {} for player in world.player_ids}
|
||||
for location in world.get_filled_locations():
|
||||
if type(location.address) == int:
|
||||
locations_data[location.player][location.address] = (location.item.code, location.item.player)
|
||||
if location.player in sending_visible_players and location.item.player != location.player:
|
||||
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)
|
||||
elif location.item.name in args.start_hints[location.item.player]:
|
||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||
location.item.code, False,
|
||||
er_hint_data.get(location.player, {}).get(location.address, ""))
|
||||
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)
|
||||
|
||||
multidata = zlib.compress(pickle.dumps({
|
||||
"slot_data": slot_data,
|
||||
"games": games,
|
||||
"names": parsed_names,
|
||||
"connect_names": connect_names,
|
||||
"remote_items": {player for player in range(1, world.players + 1) if
|
||||
world.remote_items[player]},
|
||||
"locations": locations_data,
|
||||
"checks_in_area": checks_in_area,
|
||||
"server_options": get_options()["server_options"],
|
||||
"er_hint_data": er_hint_data,
|
||||
"precollected_items": precollected_items,
|
||||
"precollected_hints": precollected_hints,
|
||||
"version": tuple(version_tuple),
|
||||
"tags": ["AP"],
|
||||
"minimum_versions": minimum_versions,
|
||||
"seed_name": world.seed_name
|
||||
}), 9)
|
||||
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:
|
||||
assert location.item.code is not None, "item code None should be event, " \
|
||||
"location.address should then also be None"
|
||||
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)
|
||||
|
||||
with open(output_path('%s.archipelago' % outfilebase), 'wb') as f:
|
||||
f.write(bytes([1])) # version of format
|
||||
f.write(multidata)
|
||||
for future in outputs:
|
||||
future.result() # collect errors if they occured
|
||||
multidata = {
|
||||
"slot_data": slot_data,
|
||||
"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},
|
||||
"remote_start_inventory": {player for player in world.player_ids if
|
||||
world.worlds[player].remote_start_inventory},
|
||||
"locations": locations_data,
|
||||
"checks_in_area": checks_in_area,
|
||||
"server_options": baked_server_options,
|
||||
"er_hint_data": er_hint_data,
|
||||
"precollected_items": precollected_items,
|
||||
"precollected_hints": precollected_hints,
|
||||
"version": tuple(version_tuple),
|
||||
"tags": ["AP"],
|
||||
"minimum_versions": minimum_versions,
|
||||
"seed_name": world.seed_name
|
||||
}
|
||||
AutoWorld.call_all(world, "modify_multidata", multidata)
|
||||
|
||||
multidata_task = pool.submit(write_multidata, rom_futures, output_file_futures)
|
||||
if not check_accessibility_task.result():
|
||||
if not world.can_beat_game():
|
||||
raise Exception("Game appears as unbeatable. Aborting.")
|
||||
else:
|
||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||
if multidata_task:
|
||||
multidata_task.result() # retrieve exception if one exists
|
||||
pool.shutdown() # wait for all queued tasks to complete
|
||||
if not args.skip_playthrough:
|
||||
logger.info('Calculating playthrough.')
|
||||
create_playthrough(world)
|
||||
if args.create_spoiler: # needs spoiler.hashes to be filled, that depend on rom_futures being done
|
||||
world.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
|
||||
multidata = zlib.compress(pickle.dumps(multidata), 9)
|
||||
|
||||
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
||||
f.write(bytes([3])) # version of format
|
||||
f.write(multidata)
|
||||
|
||||
multidata_task = pool.submit(write_multidata)
|
||||
if not check_accessibility_task.result():
|
||||
if not world.can_beat_game():
|
||||
raise Exception("Game appears as unbeatable. Aborting.")
|
||||
else:
|
||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||
|
||||
# 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):
|
||||
logger.info(f'Generating output files ({i}/{len(output_file_futures)}).')
|
||||
future.result()
|
||||
|
||||
if args.spoiler > 1:
|
||||
logger.info('Calculating playthrough.')
|
||||
create_playthrough(world)
|
||||
|
||||
if args.spoiler:
|
||||
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
||||
|
||||
zipfilename = output_path(f"AP_{world.seed_name}.zip")
|
||||
logger.info(f'Creating final archive at {zipfilename}.')
|
||||
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9) as zf:
|
||||
for file in os.scandir(temp_dir):
|
||||
zf.write(file.path, arcname=file.name)
|
||||
|
||||
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
|
||||
return world
|
||||
@@ -575,9 +443,9 @@ def create_playthrough(world):
|
||||
sphere_candidates = set(prog_locations)
|
||||
logging.debug('Building up collection spheres.')
|
||||
while sphere_candidates:
|
||||
state.sweep_for_events(key_only=True)
|
||||
|
||||
# build up spheres of collection radius. Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
||||
# build up spheres of collection radius.
|
||||
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
||||
|
||||
sphere = {location for location in sphere_candidates if state.can_reach(location)}
|
||||
|
||||
@@ -594,7 +462,7 @@ def create_playthrough(world):
|
||||
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
||||
location.item.name, location.item.player, location.name, location.player) for location in
|
||||
sphere_candidates])
|
||||
if any([world.accessibility[location.item.player] != 'none' for location in sphere_candidates]):
|
||||
if any([world.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
|
||||
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
||||
f'Something went terribly wrong here.')
|
||||
else:
|
||||
@@ -624,9 +492,9 @@ def create_playthrough(world):
|
||||
|
||||
# second phase, sphere 0
|
||||
removed_precollected = []
|
||||
for item in (i for i in world.precollected_items if i.advancement):
|
||||
for item in (i for i in chain.from_iterable(world.precollected_items.values()) if i.advancement):
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
world.precollected_items.remove(item)
|
||||
world.precollected_items[item.player].remove(item)
|
||||
world.state.remove(item)
|
||||
if not world.can_beat_game():
|
||||
world.push_precollected(item)
|
||||
@@ -678,22 +546,21 @@ def create_playthrough(world):
|
||||
world.spoiler.paths.update(
|
||||
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
|
||||
sphere if location.player == player})
|
||||
if player in world.alttp_player_ids:
|
||||
for path in dict(world.spoiler.paths).values():
|
||||
if any(exit == 'Pyramid Fairy' for (_, exit) in path):
|
||||
if world.mode[player] != 'inverted':
|
||||
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = get_path(state,
|
||||
world.get_region(
|
||||
'Big Bomb Shop',
|
||||
player))
|
||||
else:
|
||||
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = get_path(state,
|
||||
world.get_region(
|
||||
'Inverted Big Bomb Shop',
|
||||
player))
|
||||
if player in world.get_game_players("A Link to the Past"):
|
||||
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
|
||||
# Maybe move the big bomb over to the Event system instead?
|
||||
if any(exit_path == 'Pyramid Fairy' for path in world.spoiler.paths.values() for (_, exit_path) in path):
|
||||
if world.mode[player] != 'inverted':
|
||||
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = \
|
||||
get_path(state, world.get_region('Big Bomb Shop', player))
|
||||
else:
|
||||
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = \
|
||||
get_path(state, world.get_region('Inverted Big Bomb Shop', player))
|
||||
|
||||
# we can finally output our playthrough
|
||||
world.spoiler.playthrough = {"0": sorted([str(item) for item in world.precollected_items if item.advancement])}
|
||||
world.spoiler.playthrough = {"0": sorted([str(item) for item in
|
||||
chain.from_iterable(world.precollected_items.values())
|
||||
if item.advancement])}
|
||||
|
||||
for i, sphere in enumerate(collection_spheres):
|
||||
world.spoiler.playthrough[str(i + 1)] = {str(location): str(location.item) for location in sorted(sphere)}
|
||||
|
||||
286
MinecraftClient.py
Normal file
286
MinecraftClient.py
Normal file
@@ -0,0 +1,286 @@
|
||||
import argparse
|
||||
import os, sys
|
||||
import re
|
||||
import atexit
|
||||
import shutil
|
||||
from subprocess import Popen
|
||||
from shutil import copyfile
|
||||
from time import strftime
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
import Utils
|
||||
|
||||
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.1.1"
|
||||
is_windows = sys.platform in ("win32", "cygwin", "msys")
|
||||
|
||||
|
||||
def prompt_yes_no(prompt):
|
||||
yes_inputs = {'yes', 'ye', 'y'}
|
||||
no_inputs = {'no', 'n'}
|
||||
while True:
|
||||
choice = input(prompt + " [y/n] ").lower()
|
||||
if choice in yes_inputs:
|
||||
return True
|
||||
elif choice in no_inputs:
|
||||
return False
|
||||
else:
|
||||
print('Please respond with "y" or "n".')
|
||||
|
||||
|
||||
# Create mods folder if needed; find AP randomizer jar; return None if not found.
|
||||
def find_ap_randomizer_jar(forge_dir):
|
||||
mods_dir = os.path.join(forge_dir, 'mods')
|
||||
if os.path.isdir(mods_dir):
|
||||
for entry in os.scandir(mods_dir):
|
||||
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
|
||||
logging.info(f"Found AP randomizer mod: {entry.name}")
|
||||
return entry.name
|
||||
return None
|
||||
else:
|
||||
os.mkdir(mods_dir)
|
||||
logging.info(f"Created mods folder in {forge_dir}")
|
||||
return None
|
||||
|
||||
|
||||
# Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory.
|
||||
def replace_apmc_files(forge_dir, apmc_file):
|
||||
if apmc_file is None:
|
||||
return
|
||||
apdata_dir = os.path.join(forge_dir, 'APData')
|
||||
copy_apmc = True
|
||||
if not os.path.isdir(apdata_dir):
|
||||
os.mkdir(apdata_dir)
|
||||
logging.info(f"Created APData folder in {forge_dir}")
|
||||
for entry in os.scandir(apdata_dir):
|
||||
if entry.name.endswith(".apmc") and entry.is_file():
|
||||
if not os.path.samefile(apmc_file, entry.path):
|
||||
os.remove(entry.path)
|
||||
logging.info(f"Removed {entry.name} in {apdata_dir}")
|
||||
else: # apmc already in apdata
|
||||
copy_apmc = False
|
||||
if copy_apmc:
|
||||
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
|
||||
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
|
||||
|
||||
|
||||
def read_apmc_file(apmc_file):
|
||||
from base64 import b64decode
|
||||
import json
|
||||
|
||||
with open(apmc_file, 'r') as f:
|
||||
data = json.loads(b64decode(f.read()))
|
||||
return data
|
||||
|
||||
|
||||
# Check mod version, download new mod from GitHub releases page if needed.
|
||||
def update_mod(forge_dir, apmc_file, get_prereleases=False):
|
||||
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']),
|
||||
resp.json()))
|
||||
if ap_randomizer != latest_release['assets'][0]['name']:
|
||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
||||
f"{latest_release['assets'][0]['name']}")
|
||||
if ap_randomizer is not None:
|
||||
logging.info(f"Your current mod is {ap_randomizer}.")
|
||||
else:
|
||||
logging.info(f"You do not have the AP randomizer mod installed.")
|
||||
if prompt_yes_no("Would you like to update?"):
|
||||
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
|
||||
new_ap_mod = os.path.join(forge_dir, 'mods', latest_release['assets'][0]['name'])
|
||||
logging.info("Downloading AP randomizer mod. This may take a moment...")
|
||||
apmod_resp = requests.get(latest_release['assets'][0]['browser_download_url'])
|
||||
if apmod_resp.status_code == 200:
|
||||
with open(new_ap_mod, 'wb') as f:
|
||||
f.write(apmod_resp.content)
|
||||
logging.info(f"Wrote new mod file to {new_ap_mod}")
|
||||
if old_ap_mod is not None:
|
||||
os.remove(old_ap_mod)
|
||||
logging.info(f"Removed old mod file from {old_ap_mod}")
|
||||
else:
|
||||
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
||||
logging.error(f"Please report this issue on the Archipelago Discord server.")
|
||||
sys.exit(1)
|
||||
except StopIteration:
|
||||
logging.warning(f"No compatible mod version found for {minecraft_version}.")
|
||||
if not prompt_yes_no("Run server anyway?"):
|
||||
sys.exit(0)
|
||||
else:
|
||||
logging.error(f"Error checking for randomizer mod updates (status code {resp.status_code}).")
|
||||
logging.error(f"If this was not expected, please report this issue on the Archipelago Discord server.")
|
||||
if not prompt_yes_no("Continue anyways?"):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# Check if the EULA is agreed to, and prompt the user to read and agree if necessary.
|
||||
def check_eula(forge_dir):
|
||||
eula_path = os.path.join(forge_dir, "eula.txt")
|
||||
if not os.path.isfile(eula_path):
|
||||
# Create eula.txt
|
||||
with open(eula_path, 'w') as f:
|
||||
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
|
||||
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
|
||||
f.write("eula=false\n")
|
||||
with open(eula_path, 'r+') as f:
|
||||
text = f.read()
|
||||
if 'false' in text:
|
||||
# Prompt user to agree to the EULA
|
||||
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
|
||||
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
|
||||
if prompt_yes_no("Do you agree to the EULA?"):
|
||||
f.seek(0)
|
||||
f.write(text.replace('false', 'true'))
|
||||
f.truncate()
|
||||
logging.info(f"Set {eula_path} to true")
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# get the current JDK16
|
||||
def find_jdk_dir() -> str:
|
||||
for entry in os.listdir():
|
||||
if os.path.isdir(entry) and entry.startswith("jdk16"):
|
||||
return os.path.abspath(entry)
|
||||
|
||||
|
||||
# get the java exe location
|
||||
def find_jdk() -> str:
|
||||
if is_windows:
|
||||
jdk = find_jdk_dir()
|
||||
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()
|
||||
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"
|
||||
resp = requests.get(jdk_url)
|
||||
if resp.status_code == 200: # OK
|
||||
print(f"Extracting...")
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
|
||||
zf.extractall()
|
||||
else:
|
||||
print(f"Error downloading Java (status code {resp.status_code}).")
|
||||
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
|
||||
if not prompt_yes_no("Continue anyways?"):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# download and install forge
|
||||
def install_forge(directory: str):
|
||||
jdk = find_jdk()
|
||||
if jdk is not None:
|
||||
print(f"Downloading Forge {forge_version}...")
|
||||
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
|
||||
resp = requests.get(forge_url)
|
||||
if resp.status_code == 200: # OK
|
||||
forge_install_jar = os.path.join(directory, "forge_install.jar")
|
||||
if not os.path.exists(directory):
|
||||
os.mkdir(directory)
|
||||
with open(forge_install_jar, 'wb') as f:
|
||||
f.write(resp.content)
|
||||
print(f"Installing Forge...")
|
||||
argstring = ' '.join([jdk, "-jar", "\"" + forge_install_jar+ "\"", "--installServer", "\"" + directory + "\""])
|
||||
install_process = Popen(argstring, shell=not is_windows)
|
||||
install_process.wait()
|
||||
os.remove(forge_install_jar)
|
||||
|
||||
|
||||
# Run the Forge server. Return process object
|
||||
def run_forge_server(forge_dir: str, heap_arg):
|
||||
|
||||
java_exe = find_jdk()
|
||||
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()
|
||||
if heap_arg[-1] in ['b', 'B']:
|
||||
heap_arg = heap_arg[:-1]
|
||||
heap_arg = "-Xmx" + heap_arg
|
||||
|
||||
os_args = "win_args.txt" if is_windows else "unix_args.txt"
|
||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
|
||||
win_args = []
|
||||
with open(args_file) as argfile:
|
||||
for line in argfile:
|
||||
win_args.append(line.strip())
|
||||
|
||||
argstring = ' '.join([java_exe, heap_arg] + win_args + ["-nogui"])
|
||||
logging.info(f"Running Forge server: {argstring}")
|
||||
os.chdir(forge_dir)
|
||||
return Popen(argstring, shell=not is_windows)
|
||||
|
||||
|
||||
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.")
|
||||
|
||||
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"]
|
||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
||||
|
||||
if args.install:
|
||||
if is_windows:
|
||||
print("Installing Java and Minecraft Forge")
|
||||
download_java()
|
||||
else:
|
||||
print("Installing Minecraft Forge")
|
||||
install_forge(forge_dir)
|
||||
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):
|
||||
if prompt_yes_no("Did not find forge directory. Download and install forge now?"):
|
||||
install_forge(forge_dir)
|
||||
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)
|
||||
replace_apmc_files(forge_dir, apmc_file)
|
||||
check_eula(forge_dir)
|
||||
server_process = run_forge_server(forge_dir, 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,27 +24,44 @@ def update_command():
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade'])
|
||||
|
||||
|
||||
def update():
|
||||
def update(yes=False, force=False):
|
||||
global update_ran
|
||||
if not update_ran:
|
||||
update_ran = True
|
||||
if force:
|
||||
update_command()
|
||||
return
|
||||
for req_file in requirements_files:
|
||||
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
||||
if not os.path.exists(path):
|
||||
path = os.path.join(os.path.dirname(__file__), req_file)
|
||||
with open(path) as requirementsfile:
|
||||
requirements = pkg_resources.parse_requirements(requirementsfile)
|
||||
for requirement in requirements:
|
||||
requirement = str(requirement)
|
||||
try:
|
||||
pkg_resources.require(requirement)
|
||||
except pkg_resources.ResolutionError:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
input(f'Requirement {requirement} is not satisfied, press enter to install it')
|
||||
update_command()
|
||||
return
|
||||
for line in requirementsfile:
|
||||
if line.startswith('https://'):
|
||||
# extract name and version from url
|
||||
wheel = line.split('/')[-1]
|
||||
name, version, _ = wheel.split('-', 2)
|
||||
line = f'{name}=={version}'
|
||||
requirements = pkg_resources.parse_requirements(line)
|
||||
for requirement in requirements:
|
||||
requirement = str(requirement)
|
||||
try:
|
||||
pkg_resources.require(requirement)
|
||||
except pkg_resources.ResolutionError:
|
||||
if not yes:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
input(f'Requirement {requirement} is not satisfied, press enter to install it')
|
||||
update_command()
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
update()
|
||||
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')
|
||||
args = parser.parse_args()
|
||||
|
||||
update(args.yes, args.force)
|
||||
|
||||
226
MultiMystery.py
226
MultiMystery.py
@@ -1,226 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
from shutil import which
|
||||
|
||||
|
||||
def feedback(text: str):
|
||||
logging.info(text)
|
||||
input("Press Enter to ignore and probably crash.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(format='%(message)s', level=logging.INFO)
|
||||
try:
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument('--disable_autohost', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
from Utils import get_public_ipv4, get_options
|
||||
from Mystery import get_seed_name
|
||||
|
||||
options = get_options()
|
||||
|
||||
multi_mystery_options = options["multi_mystery_options"]
|
||||
output_path = options["general_options"]["output_path"]
|
||||
enemizer_path = multi_mystery_options["enemizer_path"]
|
||||
player_files_path = multi_mystery_options["player_files_path"]
|
||||
target_player_count = multi_mystery_options["players"]
|
||||
glitch_triforce = multi_mystery_options["glitch_triforce_room"]
|
||||
race = multi_mystery_options["race"]
|
||||
plando_options = multi_mystery_options["plando_options"]
|
||||
create_spoiler = multi_mystery_options["create_spoiler"]
|
||||
zip_roms = multi_mystery_options["zip_roms"]
|
||||
zip_diffs = multi_mystery_options["zip_diffs"]
|
||||
zip_apmcs = multi_mystery_options["zip_apmcs"]
|
||||
zip_spoiler = multi_mystery_options["zip_spoiler"]
|
||||
zip_multidata = multi_mystery_options["zip_multidata"]
|
||||
zip_format = multi_mystery_options["zip_format"]
|
||||
# zip_password = multi_mystery_options["zip_password"] not at this time
|
||||
meta_file_path = multi_mystery_options["meta_file_path"]
|
||||
weights_file_path = multi_mystery_options["weights_file_path"]
|
||||
pre_roll = multi_mystery_options["pre_roll"]
|
||||
teams = multi_mystery_options["teams"]
|
||||
rom_file = options["lttp_options"]["rom_file"]
|
||||
host = options["server_options"]["host"]
|
||||
port = options["server_options"]["port"]
|
||||
|
||||
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
|
||||
if not os.path.exists(enemizer_path):
|
||||
feedback(
|
||||
f"Enemizer not found at {enemizer_path}, please adjust the path in MultiMystery.py's config or put Enemizer in the default location.")
|
||||
if not os.path.exists(rom_file):
|
||||
feedback(f"Base rom is expected as {rom_file} in the Multiworld root folder please place/rename it there.")
|
||||
player_files = []
|
||||
os.makedirs(player_files_path, exist_ok=True)
|
||||
for file in os.listdir(player_files_path):
|
||||
lfile = file.lower()
|
||||
if lfile.endswith(".yaml") and lfile != meta_file_path.lower() and lfile != weights_file_path.lower():
|
||||
player_files.append(file)
|
||||
logging.info(f"Found player's file {file}.")
|
||||
|
||||
player_string = ""
|
||||
for i, file in enumerate(player_files, 1):
|
||||
player_string += f"--p{i} \"{os.path.join(player_files_path, file)}\" "
|
||||
|
||||
if os.path.exists("ArchipelagoMystery.exe"):
|
||||
basemysterycommand = "ArchipelagoMystery.exe" # compiled windows
|
||||
elif os.path.exists("ArchipelagoMystery"):
|
||||
basemysterycommand = "./ArchipelagoMystery" # compiled linux
|
||||
elif which('py'):
|
||||
basemysterycommand = f"py -{py_version} Mystery.py" # source windows
|
||||
else:
|
||||
basemysterycommand = f"python3 Mystery.py" # source others
|
||||
|
||||
weights_file_path = os.path.join(player_files_path, weights_file_path)
|
||||
if os.path.exists(weights_file_path):
|
||||
target_player_count = max(len(player_files), target_player_count)
|
||||
else:
|
||||
target_player_count = len(player_files)
|
||||
|
||||
if target_player_count == 0:
|
||||
feedback(f"No player files found. Please put them in a {player_files_path} folder.")
|
||||
else:
|
||||
logging.info(f"{target_player_count} Players found.")
|
||||
seed_name = get_seed_name(random)
|
||||
command = f"{basemysterycommand} --multi {target_player_count} {player_string} " \
|
||||
f"--rom \"{rom_file}\" --enemizercli \"{enemizer_path}\" " \
|
||||
f"--outputpath \"{output_path}\" --teams {teams} --plando \"{plando_options}\" " \
|
||||
f"--seed_name {seed_name}"
|
||||
|
||||
if create_spoiler:
|
||||
command += " --create_spoiler"
|
||||
if create_spoiler == 2:
|
||||
command += " --skip_playthrough"
|
||||
if zip_diffs:
|
||||
command += " --create_diff"
|
||||
if glitch_triforce:
|
||||
command += " --glitch_triforce"
|
||||
if race:
|
||||
command += " --race"
|
||||
if os.path.exists(os.path.join(player_files_path, meta_file_path)):
|
||||
command += f" --meta {os.path.join(player_files_path, meta_file_path)}"
|
||||
if os.path.exists(weights_file_path):
|
||||
command += f" --weights {weights_file_path}"
|
||||
if pre_roll:
|
||||
command += " --pre_roll"
|
||||
|
||||
logging.info(command)
|
||||
import time
|
||||
|
||||
start = time.perf_counter()
|
||||
text = subprocess.check_output(command, shell=True).decode()
|
||||
logging.info(f"Took {time.perf_counter() - start:.3f} seconds to generate multiworld.")
|
||||
|
||||
multidataname = f"AP_{seed_name}.archipelago"
|
||||
spoilername = f"AP_{seed_name}_Spoiler.txt"
|
||||
romfilename = ""
|
||||
|
||||
if any((zip_roms, zip_multidata, zip_spoiler, zip_diffs, zip_apmcs)):
|
||||
import zipfile
|
||||
|
||||
compression = {1: zipfile.ZIP_DEFLATED,
|
||||
2: zipfile.ZIP_LZMA,
|
||||
3: zipfile.ZIP_BZIP2}[zip_format]
|
||||
|
||||
typical_zip_ending = {1: "zip",
|
||||
2: "7z",
|
||||
3: "bz2"}[zip_format]
|
||||
|
||||
ziplock = threading.Lock()
|
||||
|
||||
|
||||
def pack_file(file: str):
|
||||
with ziplock:
|
||||
zf.write(os.path.join(output_path, file), file)
|
||||
logging.info(f"Packed {file} into zipfile {zipname}")
|
||||
|
||||
|
||||
def remove_zipped_file(file: str):
|
||||
os.remove(os.path.join(output_path, file))
|
||||
logging.info(f"Removed {file} which is now present in the zipfile")
|
||||
|
||||
|
||||
zipname = os.path.join(output_path, f"AP_{seed_name}.{typical_zip_ending}")
|
||||
|
||||
logging.info(f"Creating zipfile {zipname}")
|
||||
ipv4 = (host if host else get_public_ipv4()) + ":" + str(port)
|
||||
|
||||
|
||||
def _handle_sfc_file(file: str):
|
||||
if zip_roms:
|
||||
pack_file(file)
|
||||
if zip_roms == 2:
|
||||
remove_zipped_file(file)
|
||||
|
||||
|
||||
def _handle_diff_file(file: str):
|
||||
if zip_diffs > 0:
|
||||
pack_file(file)
|
||||
if zip_diffs == 2:
|
||||
remove_zipped_file(file)
|
||||
|
||||
|
||||
def _handle_apmc_file(file: str):
|
||||
if zip_apmcs:
|
||||
pack_file(file)
|
||||
if zip_apmcs == 2:
|
||||
remove_zipped_file(file)
|
||||
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
futures = []
|
||||
files = os.listdir(output_path)
|
||||
with zipfile.ZipFile(zipname, "w", compression=compression, compresslevel=9) as zf:
|
||||
for file in files:
|
||||
if seed_name in file:
|
||||
if file.endswith(".sfc"):
|
||||
futures.append(pool.submit(_handle_sfc_file, file))
|
||||
elif file.endswith(".apbp"):
|
||||
futures.append(pool.submit(_handle_diff_file, file))
|
||||
elif file.endswith(".apmc"):
|
||||
futures.append(pool.submit(_handle_apmc_file, file))
|
||||
# just handle like a diff file for now
|
||||
elif file.endswith(".zip"):
|
||||
futures.append(pool.submit(_handle_diff_file, file))
|
||||
|
||||
if zip_multidata and os.path.exists(os.path.join(output_path, multidataname)):
|
||||
pack_file(multidataname)
|
||||
if zip_multidata == 2:
|
||||
remove_zipped_file(multidataname)
|
||||
|
||||
if zip_spoiler and create_spoiler:
|
||||
pack_file(spoilername)
|
||||
if zip_spoiler == 2:
|
||||
remove_zipped_file(spoilername)
|
||||
|
||||
for future in futures:
|
||||
future.result() # make sure we close the zip AFTER any packing is done
|
||||
|
||||
if not args.disable_autohost:
|
||||
if os.path.exists(os.path.join(output_path, multidataname)):
|
||||
if os.path.exists("ArchipelagoServer.exe"):
|
||||
baseservercommand = ["ArchipelagoServer.exe"] # compiled windows
|
||||
elif os.path.exists("ArchipelagoServer"):
|
||||
baseservercommand = ["./ArchipelagoServer"] # compiled linux
|
||||
elif which('py'):
|
||||
baseservercommand = ["py", f"-{py_version}", "MultiServer.py"] # source windows
|
||||
else:
|
||||
baseservercommand = ["python3", "MultiServer.py"] # source others
|
||||
# don't have a mac to test that. If you try to run compiled on mac, good luck.
|
||||
subprocess.call(baseservercommand + ["--multidata", os.path.join(output_path, multidataname)])
|
||||
except:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
input("Press enter to close")
|
||||
1236
MultiServer.py
1236
MultiServer.py
File diff suppressed because it is too large
Load Diff
821
Mystery.py
821
Mystery.py
@@ -1,821 +0,0 @@
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import typing
|
||||
import os
|
||||
from collections import Counter
|
||||
import string
|
||||
|
||||
import ModuleUpdate
|
||||
from worlds.alttp import Options as LttPOptions
|
||||
from worlds.generic import PlandoItem, PlandoConnection
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
from Utils import parse_yaml, version_tuple, __version__, tuplize_version
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from Main import get_seed, seeddigits
|
||||
import Options
|
||||
from worlds import lookup_any_item_name_to_id
|
||||
from worlds.alttp.Items import item_name_groups, item_table
|
||||
from worlds.alttp import Bosses
|
||||
from worlds.alttp.Text import TextTable
|
||||
from worlds.alttp.Regions import location_table, key_drop_data
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
categories = set(AutoWorldRegister.world_types)
|
||||
|
||||
def mystery_argparse():
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument('--multi', default=1, type=lambda value: min(max(int(value), 1), 255))
|
||||
multiargs, _ = parser.parse_known_args()
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--weights',
|
||||
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('--seed', help='Define seed number to generate.', type=int)
|
||||
parser.add_argument('--multi', default=1, type=lambda value: min(max(int(value), 1), 255))
|
||||
parser.add_argument('--teams', default=1, type=lambda value: max(int(value), 1))
|
||||
parser.add_argument('--create_spoiler', action='store_true')
|
||||
parser.add_argument('--skip_playthrough', action='store_true')
|
||||
parser.add_argument('--pre_roll', action='store_true')
|
||||
parser.add_argument('--rom')
|
||||
parser.add_argument('--enemizercli')
|
||||
parser.add_argument('--outputpath')
|
||||
parser.add_argument('--glitch_triforce', action='store_true')
|
||||
parser.add_argument('--race', action='store_true')
|
||||
parser.add_argument('--meta', default=None)
|
||||
parser.add_argument('--log_output_path', help='Path to store output log')
|
||||
parser.add_argument('--loglevel', default='info', help='Sets log level')
|
||||
parser.add_argument('--create_diff', action="store_true")
|
||||
parser.add_argument('--yaml_output', default=0, type=lambda value: min(max(int(value), 0), 255),
|
||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
||||
parser.add_argument('--plando', default="bosses",
|
||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
||||
parser.add_argument('--seed_name')
|
||||
for player in range(1, multiargs.multi + 1):
|
||||
parser.add_argument(f'--p{player}', help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
|
||||
return args
|
||||
|
||||
|
||||
def get_seed_name(random):
|
||||
return f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||
|
||||
|
||||
def main(args=None, callback=ERmain):
|
||||
if not args:
|
||||
args = mystery_argparse()
|
||||
|
||||
seed = get_seed(args.seed)
|
||||
random.seed(seed)
|
||||
seed_name = args.seed_name if args.seed_name else get_seed_name(random)
|
||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed}")
|
||||
|
||||
if args.race:
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
weights_cache = {}
|
||||
if args.weights:
|
||||
try:
|
||||
weights_cache[args.weights] = read_weights_yaml(args.weights)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.weights} is destroyed. Please fix your yaml.") from e
|
||||
print(f"Weights: {args.weights} >> "
|
||||
f"{get_choice('description', weights_cache[args.weights], 'No description specified')}")
|
||||
if args.meta:
|
||||
try:
|
||||
weights_cache[args.meta] = read_weights_yaml(args.meta)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.meta} is destroyed. Please fix your yaml.") from e
|
||||
meta_weights = weights_cache[args.meta]
|
||||
print(f"Meta: {args.meta} >> {get_choice('meta_description', meta_weights, 'No description specified')}")
|
||||
if args.samesettings:
|
||||
raise Exception("Cannot mix --samesettings with --meta")
|
||||
|
||||
for player in range(1, args.multi + 1):
|
||||
path = getattr(args, f'p{player}')
|
||||
if path:
|
||||
try:
|
||||
if path not in weights_cache:
|
||||
weights_cache[path] = read_weights_yaml(path)
|
||||
print(f"P{player} Weights: {path} >> "
|
||||
f"{get_choice('description', weights_cache[path], 'No description specified')}")
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||
erargs.seed = seed
|
||||
erargs.name = {x: "" for x in range(1, args.multi + 1)} # only so it can be overwrittin in mystery
|
||||
erargs.create_spoiler = args.create_spoiler
|
||||
erargs.create_diff = args.create_diff
|
||||
erargs.glitch_triforce = args.glitch_triforce
|
||||
erargs.race = args.race
|
||||
erargs.skip_playthrough = args.skip_playthrough
|
||||
erargs.outputname = seed_name
|
||||
erargs.outputpath = args.outputpath
|
||||
erargs.teams = args.teams
|
||||
|
||||
# set up logger
|
||||
if args.loglevel:
|
||||
erargs.loglevel = args.loglevel
|
||||
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
|
||||
erargs.loglevel]
|
||||
|
||||
if args.log_output_path:
|
||||
import sys
|
||||
class LoggerWriter(object):
|
||||
def __init__(self, writer):
|
||||
self._writer = writer
|
||||
self._msg = ''
|
||||
|
||||
def write(self, message):
|
||||
self._msg = self._msg + message
|
||||
while '\n' in self._msg:
|
||||
pos = self._msg.find('\n')
|
||||
self._writer(self._msg[:pos])
|
||||
self._msg = self._msg[pos + 1:]
|
||||
|
||||
def flush(self):
|
||||
if self._msg != '':
|
||||
self._writer(self._msg)
|
||||
self._msg = ''
|
||||
|
||||
log = logging.getLogger("stderr")
|
||||
log.addHandler(logging.StreamHandler())
|
||||
sys.stderr = LoggerWriter(log.error)
|
||||
os.makedirs(args.log_output_path, exist_ok=True)
|
||||
logging.basicConfig(format='%(message)s', level=loglevel,
|
||||
filename=os.path.join(args.log_output_path, f"{seed}.log"))
|
||||
else:
|
||||
logging.basicConfig(format='%(message)s', level=loglevel)
|
||||
if args.rom:
|
||||
erargs.rom = args.rom
|
||||
|
||||
if args.enemizercli:
|
||||
erargs.enemizercli = args.enemizercli
|
||||
|
||||
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
|
||||
for k, v in weights_cache.items()}
|
||||
player_path_cache = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
player_path_cache[player] = getattr(args, f'p{player}') if getattr(args, f'p{player}') else args.weights
|
||||
|
||||
if args.meta:
|
||||
for player, path in player_path_cache.items():
|
||||
weights_cache[path].setdefault("meta_ignore", [])
|
||||
meta_weights = weights_cache[args.meta]
|
||||
for key in meta_weights:
|
||||
option = get_choice(key, meta_weights)
|
||||
if option is not None:
|
||||
for player, path in player_path_cache.items():
|
||||
players_meta = weights_cache[path].get("meta_ignore", [])
|
||||
if key not in players_meta:
|
||||
weights_cache[path][key] = option
|
||||
elif type(players_meta) == dict and players_meta[key] and option not in players_meta[key]:
|
||||
weights_cache[path][key] = option
|
||||
|
||||
name_counter = Counter()
|
||||
erargs.player_settings = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
path = player_path_cache[player]
|
||||
if path:
|
||||
try:
|
||||
settings = settings_cache[path] if settings_cache[path] else \
|
||||
roll_settings(weights_cache[path], args.plando)
|
||||
if args.pre_roll:
|
||||
import yaml
|
||||
if path == args.weights:
|
||||
settings.name = f"Player{player}"
|
||||
elif not settings.name:
|
||||
settings.name = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
|
||||
if "-" not in settings.shuffle and settings.shuffle != "vanilla":
|
||||
settings.shuffle += f"-{random.randint(0, 2 ** 64)}"
|
||||
|
||||
pre_rolled = dict()
|
||||
pre_rolled["original_seed_number"] = seed
|
||||
pre_rolled["original_seed_name"] = seed_name
|
||||
pre_rolled["pre_rolled"] = vars(settings).copy()
|
||||
if "plando_items" in pre_rolled["pre_rolled"]:
|
||||
pre_rolled["pre_rolled"]["plando_items"] = [item.to_dict() for item in
|
||||
pre_rolled["pre_rolled"]["plando_items"]]
|
||||
if "plando_connections" in pre_rolled["pre_rolled"]:
|
||||
pre_rolled["pre_rolled"]["plando_connections"] = [connection.to_dict() for connection in
|
||||
pre_rolled["pre_rolled"][
|
||||
"plando_connections"]]
|
||||
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".",
|
||||
f"{os.path.split(path)[-1].split('.')[0]}_pre_rolled.yaml"), "wt") as f:
|
||||
yaml.dump(pre_rolled, f)
|
||||
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 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: # 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)
|
||||
|
||||
erargs.names = ",".join(erargs.name[i] for i in range(1, args.multi + 1))
|
||||
del (erargs.name)
|
||||
if args.yaml_output:
|
||||
import yaml
|
||||
important = {}
|
||||
for option, player_settings in vars(erargs).items():
|
||||
if type(player_settings) == dict:
|
||||
if all(type(value) != list for value in player_settings.values()):
|
||||
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}'")
|
||||
|
||||
else:
|
||||
if player_settings != "": # is not empty name
|
||||
important[option] = player_settings
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
if args.outputpath:
|
||||
os.makedirs(args.outputpath, exist_ok=True)
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"mystery_result_{seed}.yaml"), "wt") as f:
|
||||
yaml.dump(important, f)
|
||||
|
||||
callback(erargs, seed)
|
||||
|
||||
|
||||
def read_weights_yaml(path):
|
||||
try:
|
||||
if urllib.parse.urlparse(path).scheme:
|
||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8")
|
||||
else:
|
||||
with open(path, 'rb') as f:
|
||||
yaml = str(f.read(), "utf-8")
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to read weights ({path})") from e
|
||||
|
||||
return parse_yaml(yaml)
|
||||
|
||||
|
||||
def interpret_on_off(value):
|
||||
return {"on": True, "off": False}.get(value, value)
|
||||
|
||||
|
||||
def convert_to_on_off(value):
|
||||
return {True: "on", False: "off"}.get(value, value)
|
||||
|
||||
|
||||
def get_choice(option, root, value=None) -> typing.Any:
|
||||
if option not in root:
|
||||
return value
|
||||
if type(root[option]) is list:
|
||||
return interpret_on_off(random.choices(root[option])[0])
|
||||
if type(root[option]) is not dict:
|
||||
return interpret_on_off(root[option])
|
||||
if not root[option]:
|
||||
return value
|
||||
if any(root[option].values()):
|
||||
return interpret_on_off(
|
||||
random.choices(list(root[option].keys()), weights=list(map(int, root[option].values())))[0])
|
||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||
|
||||
|
||||
class SafeDict(dict):
|
||||
def __missing__(self, key):
|
||||
return '{' + key + '}'
|
||||
|
||||
|
||||
def handle_name(name: str, player: int, name_counter: Counter):
|
||||
name_counter[name] += 1
|
||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
|
||||
NUMBER=(name_counter[name] if name_counter[
|
||||
name] > 1 else ''),
|
||||
player=player,
|
||||
PLAYER=(player if player > 1 else '')))
|
||||
new_name = new_name.strip().replace(' ', '_')[:16]
|
||||
if new_name == "Archipelago":
|
||||
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
||||
return new_name
|
||||
|
||||
|
||||
def prefer_int(input_data: str) -> typing.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
|
||||
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
||||
available_boss_locations: typing.Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
||||
Bosses.boss_location_table}
|
||||
|
||||
boss_shuffle_options = {None: 'none',
|
||||
'none': 'none',
|
||||
'basic': 'basic',
|
||||
'full': 'full',
|
||||
'chaos': 'chaos',
|
||||
'singularity': 'singularity'
|
||||
}
|
||||
|
||||
goals = {
|
||||
'ganon': 'ganon',
|
||||
'crystals': 'crystals',
|
||||
'bosses': 'bosses',
|
||||
'pedestal': 'pedestal',
|
||||
'ganon_pedestal': 'ganonpedestal',
|
||||
'triforce_hunt': 'triforcehunt',
|
||||
'local_triforce_hunt': 'localtriforcehunt',
|
||||
'ganon_triforce_hunt': 'ganontriforcehunt',
|
||||
'local_ganon_triforce_hunt': 'localganontriforcehunt',
|
||||
'ice_rod_hunt': 'icerodhunt',
|
||||
}
|
||||
|
||||
|
||||
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
|
||||
"""Roll a percentage chance.
|
||||
percentage is expected to be in range [0, 100]"""
|
||||
return random.random() < (float(percentage) / 100)
|
||||
|
||||
|
||||
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
|
||||
logging.debug(f'Applying {new_weights}')
|
||||
new_options = set(new_weights) - set(weights)
|
||||
weights.update(new_weights)
|
||||
if new_options:
|
||||
for new_option in new_options:
|
||||
logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
|
||||
f'overwrite a root option. '
|
||||
f'This is probably in error.')
|
||||
return weights
|
||||
|
||||
|
||||
def roll_linked_options(weights: dict) -> dict:
|
||||
weights = weights.copy() # 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.")
|
||||
try:
|
||||
if roll_percentage(option_set["percentage"]):
|
||||
logging.debug(f"Linked option {option_set['name']} triggered.")
|
||||
new_options = option_set["options"]
|
||||
for category_name, category_options in new_options.items():
|
||||
currently_targeted_weights = weights
|
||||
if category_name:
|
||||
currently_targeted_weights = currently_targeted_weights[category_name]
|
||||
update_weights(currently_targeted_weights, category_options, "Linked", option_set["name"])
|
||||
else:
|
||||
logging.debug(f"linked option {option_set['name']} skipped.")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Linked option {option_set['name']} is destroyed. "
|
||||
f"Please fix your linked option.") from e
|
||||
return weights
|
||||
|
||||
|
||||
def roll_triggers(weights: dict) -> dict:
|
||||
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
|
||||
weights["_Generator_Version"] = "Archipelago" # Some means for triggers to know if the seed is on main or doors.
|
||||
for i, option_set in enumerate(weights["triggers"]):
|
||||
try:
|
||||
currently_targeted_weights = weights
|
||||
category = option_set.get("option_category", None)
|
||||
if category:
|
||||
currently_targeted_weights = currently_targeted_weights[category]
|
||||
key = get_choice("option_name", option_set)
|
||||
if key not in currently_targeted_weights:
|
||||
logging.warning(f'Specified option name {option_set["option_name"]} did not '
|
||||
f'match with a root option. '
|
||||
f'This is probably in error.')
|
||||
trigger_result = get_choice("option_result", option_set)
|
||||
result = get_choice(key, currently_targeted_weights)
|
||||
currently_targeted_weights[key] = result
|
||||
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
|
||||
for category_name, category_options in option_set["options"].items():
|
||||
currently_targeted_weights = weights
|
||||
if category_name:
|
||||
currently_targeted_weights = currently_targeted_weights[category_name]
|
||||
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Your trigger number {i + 1} is destroyed. "
|
||||
f"Please fix your triggers.") from e
|
||||
return weights
|
||||
|
||||
|
||||
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
|
||||
if boss_shuffle in boss_shuffle_options:
|
||||
return boss_shuffle_options[boss_shuffle]
|
||||
elif "bosses" in plando_options:
|
||||
options = boss_shuffle.lower().split(";")
|
||||
remainder_shuffle = "none" # vanilla
|
||||
bosses = []
|
||||
for boss in options:
|
||||
if boss in boss_shuffle_options:
|
||||
remainder_shuffle = boss_shuffle_options[boss]
|
||||
elif "-" in boss:
|
||||
loc, boss_name = boss.split("-")
|
||||
if boss_name not in available_boss_names:
|
||||
raise ValueError(f"Unknown Boss name {boss_name}")
|
||||
if loc not in available_boss_locations:
|
||||
raise ValueError(f"Unknown Boss Location {loc}")
|
||||
level = ''
|
||||
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
||||
# split off level
|
||||
loc = loc.split(" ")
|
||||
level = f" {loc[-1]}"
|
||||
loc = " ".join(loc[:-1])
|
||||
loc = loc.title().replace("Of", "of")
|
||||
if not Bosses.can_place_boss(boss_name.title(), loc, level):
|
||||
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
|
||||
bosses.append(boss)
|
||||
elif boss not in available_boss_names:
|
||||
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
|
||||
else:
|
||||
bosses.append(boss)
|
||||
return ";".join(bosses + [remainder_shuffle])
|
||||
else:
|
||||
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
|
||||
|
||||
|
||||
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))):
|
||||
if "pre_rolled" in weights:
|
||||
pre_rolled = weights["pre_rolled"]
|
||||
|
||||
if "plando_items" in pre_rolled:
|
||||
pre_rolled["plando_items"] = [PlandoItem(item["item"],
|
||||
item["location"],
|
||||
item["world"],
|
||||
item["from_pool"],
|
||||
item["force"]) for item in pre_rolled["plando_items"]]
|
||||
if "items" not in plando_options and pre_rolled["plando_items"]:
|
||||
raise Exception("Item Plando is turned off. Reusing this pre-rolled setting not permitted.")
|
||||
|
||||
if "plando_connections" in pre_rolled:
|
||||
pre_rolled["plando_connections"] = [PlandoConnection(connection["entrance"],
|
||||
connection["exit"],
|
||||
connection["direction"]) for connection in
|
||||
pre_rolled["plando_connections"]]
|
||||
if "connections" not in plando_options and pre_rolled["plando_connections"]:
|
||||
raise Exception("Connection Plando is turned off. Reusing this pre-rolled setting not permitted.")
|
||||
|
||||
if "bosses" not in plando_options:
|
||||
try:
|
||||
pre_rolled["shufflebosses"] = get_plando_bosses(pre_rolled["shufflebosses"], plando_options)
|
||||
except Exception as ex:
|
||||
raise Exception("Boss Plando is turned off. Reusing this pre-rolled setting not permitted.") from ex
|
||||
|
||||
if pre_rolled.get("plando_texts") and "texts" not in plando_options:
|
||||
raise Exception("Text Plando is turned off. Reusing this pre-rolled setting not permitted.")
|
||||
|
||||
return argparse.Namespace(**pre_rolled)
|
||||
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
if "triggers" in weights:
|
||||
weights = roll_triggers(weights)
|
||||
|
||||
requirements = weights.get("requires", {})
|
||||
if requirements:
|
||||
version = requirements.get("version", __version__)
|
||||
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
|
||||
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.")
|
||||
|
||||
ret = argparse.Namespace()
|
||||
ret.name = get_choice('name', weights)
|
||||
ret.accessibility = get_choice('accessibility', weights)
|
||||
ret.progression_balancing = get_choice('progression_balancing', weights, True)
|
||||
ret.game = get_choice("game", weights)
|
||||
if ret.game not in weights:
|
||||
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
|
||||
game_weights = weights[ret.game]
|
||||
ret.local_items = set()
|
||||
for item_name in game_weights.get('local_items', []):
|
||||
items = item_name_groups.get(item_name, {item_name})
|
||||
for item in items:
|
||||
if item in lookup_any_item_name_to_id:
|
||||
ret.local_items.add(item)
|
||||
else:
|
||||
raise Exception(f"Could not force item {item} to be world-local, as it was not recognized.")
|
||||
|
||||
ret.non_local_items = set()
|
||||
for item_name in game_weights.get('non_local_items', []):
|
||||
items = item_name_groups.get(item_name, {item_name})
|
||||
for item in items:
|
||||
if item in lookup_any_item_name_to_id:
|
||||
ret.non_local_items.add(item)
|
||||
else:
|
||||
raise Exception(f"Could not force item {item} to be world-non-local, as it was not recognized.")
|
||||
|
||||
inventoryweights = game_weights.get('start_inventory', {})
|
||||
startitems = []
|
||||
for item in inventoryweights.keys():
|
||||
itemvalue = get_choice(item, inventoryweights)
|
||||
if isinstance(itemvalue, int):
|
||||
for i in range(int(itemvalue)):
|
||||
startitems.append(item)
|
||||
elif itemvalue:
|
||||
startitems.append(item)
|
||||
ret.startinventory = startitems
|
||||
ret.start_hints = set(game_weights.get('start_hints', []))
|
||||
|
||||
if ret.game in AutoWorldRegister.world_types:
|
||||
for option_name, option in AutoWorldRegister.world_types[ret.game].options.items():
|
||||
if option_name in game_weights:
|
||||
try:
|
||||
if issubclass(option, Options.OptionDict):
|
||||
setattr(ret, option_name, option.from_any(game_weights[option_name]))
|
||||
else:
|
||||
setattr(ret, option_name, option.from_any(get_choice(option_name, game_weights)))
|
||||
except Exception as e:
|
||||
raise Exception(f"Error generating option {option_name} in {ret.game}")
|
||||
else:
|
||||
setattr(ret, option_name, option(option.default))
|
||||
if ret.game == "Minecraft":
|
||||
# bad hardcoded behavior to make this work for now
|
||||
ret.plando_connections = []
|
||||
if "connections" in plando_options:
|
||||
options = game_weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice("entrance", placement),
|
||||
get_choice("exit", placement),
|
||||
get_choice("direction", placement, "both")
|
||||
))
|
||||
elif ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, game_weights, plando_options)
|
||||
else:
|
||||
raise Exception(f"Unsupported game {ret.game}")
|
||||
return ret
|
||||
|
||||
|
||||
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
glitches_required = get_choice('glitches_required', weights)
|
||||
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
|
||||
logging.warning("Only NMG, OWG, HMG and No Logic supported")
|
||||
glitches_required = 'none'
|
||||
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
|
||||
'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
|
||||
glitches_required]
|
||||
|
||||
ret.dark_room_logic = get_choice("dark_room_logic", weights, "lamp")
|
||||
if not ret.dark_room_logic: # None/False
|
||||
ret.dark_room_logic = "none"
|
||||
if ret.dark_room_logic == "sconces":
|
||||
ret.dark_room_logic = "torches"
|
||||
if ret.dark_room_logic not in {"lamp", "torches", "none"}:
|
||||
raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
|
||||
|
||||
ret.restrict_dungeon_item_on_boss = get_choice('restrict_dungeon_item_on_boss', weights, False)
|
||||
|
||||
dungeon_items = get_choice('dungeon_items', weights)
|
||||
if dungeon_items == 'full' or dungeon_items == True:
|
||||
dungeon_items = 'mcsb'
|
||||
elif dungeon_items == 'standard':
|
||||
dungeon_items = ""
|
||||
elif not dungeon_items:
|
||||
dungeon_items = ""
|
||||
if "u" in dungeon_items:
|
||||
dungeon_items.replace("s", "")
|
||||
|
||||
ret.mapshuffle = get_choice('map_shuffle', weights, 'm' in dungeon_items)
|
||||
ret.compassshuffle = get_choice('compass_shuffle', weights, 'c' in dungeon_items)
|
||||
ret.keyshuffle = get_choice('smallkey_shuffle', weights,
|
||||
'universal' if 'u' in dungeon_items else 's' in dungeon_items)
|
||||
ret.bigkeyshuffle = get_choice('bigkey_shuffle', weights, 'b' in dungeon_items)
|
||||
|
||||
entrance_shuffle = get_choice('entrance_shuffle', weights, 'vanilla')
|
||||
if entrance_shuffle.startswith('none-'):
|
||||
ret.shuffle = 'vanilla'
|
||||
else:
|
||||
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
|
||||
|
||||
goal = get_choice('goals', weights, 'ganon')
|
||||
|
||||
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('open_pyramid', weights, 'goal')
|
||||
|
||||
extra_pieces = get_choice('triforce_pieces_mode', weights, 'available')
|
||||
|
||||
ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice('triforce_pieces_required', weights, 20))
|
||||
|
||||
# sum a percentage to required
|
||||
if extra_pieces == 'percentage':
|
||||
percentage = max(100, float(get_choice('triforce_pieces_percentage', weights, 150))) / 100
|
||||
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
|
||||
# vanilla mode (specify how many pieces are)
|
||||
elif extra_pieces == 'available':
|
||||
ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any(
|
||||
get_choice('triforce_pieces_available', weights, 30))
|
||||
# required pieces + fixed extra
|
||||
elif extra_pieces == 'extra':
|
||||
extra_pieces = max(0, int(get_choice('triforce_pieces_extra', weights, 10)))
|
||||
ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
|
||||
|
||||
# change minimum to required pieces to avoid problems
|
||||
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
|
||||
|
||||
ret.shop_shuffle = get_choice('shop_shuffle', weights, '')
|
||||
if not ret.shop_shuffle:
|
||||
ret.shop_shuffle = ''
|
||||
|
||||
ret.mode = get_choice("mode", weights)
|
||||
ret.retro = get_choice("retro", weights)
|
||||
|
||||
ret.hints = get_choice('hints', weights)
|
||||
|
||||
ret.swordless = get_choice('swordless', weights, False)
|
||||
|
||||
ret.difficulty = get_choice('item_pool', weights)
|
||||
|
||||
ret.item_functionality = get_choice('item_functionality', weights)
|
||||
|
||||
boss_shuffle = get_choice('boss_shuffle', weights)
|
||||
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
|
||||
|
||||
ret.enemy_shuffle = bool(get_choice('enemy_shuffle', weights, False))
|
||||
|
||||
ret.killable_thieves = get_choice('killable_thieves', weights, False)
|
||||
ret.tile_shuffle = get_choice('tile_shuffle', weights, False)
|
||||
ret.bush_shuffle = get_choice('bush_shuffle', weights, False)
|
||||
|
||||
ret.enemy_damage = {None: 'default',
|
||||
'default': 'default',
|
||||
'shuffled': 'shuffled',
|
||||
'random': 'chaos'
|
||||
}[get_choice('enemy_damage', weights)]
|
||||
|
||||
ret.enemy_health = get_choice('enemy_health', weights)
|
||||
|
||||
ret.shufflepots = get_choice('pot_shuffle', weights)
|
||||
|
||||
ret.beemizer = int(get_choice('beemizer', weights, 0))
|
||||
|
||||
ret.timer = {'none': False,
|
||||
None: False,
|
||||
False: False,
|
||||
'timed': 'timed',
|
||||
'timed_ohko': 'timed-ohko',
|
||||
'ohko': 'ohko',
|
||||
'timed_countdown': 'timed-countdown',
|
||||
'display': 'display'}[get_choice('timer', weights, False)]
|
||||
|
||||
ret.countdown_start_time = int(get_choice('countdown_start_time', weights, 10))
|
||||
ret.red_clock_time = int(get_choice('red_clock_time', weights, -2))
|
||||
ret.blue_clock_time = int(get_choice('blue_clock_time', weights, 2))
|
||||
ret.green_clock_time = int(get_choice('green_clock_time', weights, 4))
|
||||
|
||||
ret.dungeon_counters = get_choice('dungeon_counters', weights, 'default')
|
||||
|
||||
ret.progressive = convert_to_on_off(get_choice('progressive', weights, 'on'))
|
||||
|
||||
ret.shuffle_prizes = get_choice('shuffle_prizes', weights, "g")
|
||||
|
||||
ret.required_medallions = [get_choice("misery_mire_medallion", weights, "random"),
|
||||
get_choice("turtle_rock_medallion", weights, "random")]
|
||||
|
||||
for index, medallion in enumerate(ret.required_medallions):
|
||||
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
|
||||
.get(medallion.lower(), None)
|
||||
if not ret.required_medallions[index]:
|
||||
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||
|
||||
ret.glitch_boots = get_choice('glitch_boots', weights, True)
|
||||
|
||||
if get_choice("local_keys", weights, "l" in dungeon_items):
|
||||
# () important for ordering of commands, without them the Big Keys section is part of the Small Key else
|
||||
ret.local_items |= item_name_groups["Small Keys"] if ret.keyshuffle else set()
|
||||
ret.local_items |= item_name_groups["Big Keys"] if ret.bigkeyshuffle else set()
|
||||
|
||||
ret.plando_items = []
|
||||
if "items" in plando_options:
|
||||
|
||||
def add_plando_item(item: str, location: str):
|
||||
if item not in item_table:
|
||||
raise Exception(f"Could not plando item {item} as the item was not recognized")
|
||||
if location not in location_table and location not in key_drop_data:
|
||||
raise Exception(
|
||||
f"Could not plando item {item} at location {location} as the location was not recognized")
|
||||
ret.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("percentage", placement, 100)):
|
||||
from_pool = get_choice("from_pool", placement, PlandoItem._field_defaults["from_pool"])
|
||||
location_world = get_choice("world", placement, PlandoItem._field_defaults["world"])
|
||||
force = str(get_choice("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("item", placement, get_choice("items", placement))
|
||||
location = get_choice("location", placement)
|
||||
add_plando_item(item, location)
|
||||
|
||||
ret.plando_texts = {}
|
||||
if "texts" in plando_options:
|
||||
tt = TextTable()
|
||||
tt.removeUnwantedText()
|
||||
options = weights.get("plando_texts", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
at = str(get_choice("at", placement))
|
||||
if at not in tt:
|
||||
raise Exception(f"No text target \"{at}\" found.")
|
||||
ret.plando_texts[at] = str(get_choice("text", placement))
|
||||
|
||||
ret.plando_connections = []
|
||||
if "connections" in plando_options:
|
||||
options = weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice("entrance", placement),
|
||||
get_choice("exit", placement),
|
||||
get_choice("direction", placement, "both")
|
||||
))
|
||||
|
||||
|
||||
ret.sprite_pool = weights.get('sprite_pool', [])
|
||||
ret.sprite = get_choice('sprite', weights, "Link")
|
||||
if 'random_sprite_on_event' in weights:
|
||||
randomoneventweights = weights['random_sprite_on_event']
|
||||
if get_choice('enabled', randomoneventweights, False):
|
||||
ret.sprite = 'randomon'
|
||||
ret.sprite += '-hit' if get_choice('on_hit', randomoneventweights, True) else ''
|
||||
ret.sprite += '-enter' if get_choice('on_enter', randomoneventweights, False) else ''
|
||||
ret.sprite += '-exit' if get_choice('on_exit', randomoneventweights, False) else ''
|
||||
ret.sprite += '-slash' if get_choice('on_slash', randomoneventweights, False) else ''
|
||||
ret.sprite += '-item' if get_choice('on_item', randomoneventweights, False) else ''
|
||||
ret.sprite += '-bonk' if get_choice('on_bonk', randomoneventweights, False) else ''
|
||||
ret.sprite = 'randomonall' if get_choice('on_everything', randomoneventweights, False) else ret.sprite
|
||||
ret.sprite = 'randomonnone' if ret.sprite == 'randomon' else ret.sprite
|
||||
|
||||
if (not ret.sprite_pool or get_choice('use_weighted_sprite_pool', randomoneventweights, False)) \
|
||||
and 'sprite' in weights: # Use sprite as a weighted sprite pool, if a sprite pool is not already defined.
|
||||
for key, value in weights['sprite'].items():
|
||||
if key.startswith('random'):
|
||||
ret.sprite_pool += ['random'] * int(value)
|
||||
else:
|
||||
ret.sprite_pool += [key] * int(value)
|
||||
|
||||
ret.disablemusic = get_choice('disablemusic', weights, False)
|
||||
ret.triforcehud = get_choice('triforcehud', weights, 'hide_goal')
|
||||
ret.quickswap = get_choice('quickswap', weights, True)
|
||||
ret.fastmenu = get_choice('menuspeed', weights, "normal")
|
||||
ret.reduceflashing = get_choice('reduceflashing', weights, False)
|
||||
ret.heartcolor = get_choice('heartcolor', weights, "red")
|
||||
ret.heartbeep = convert_to_on_off(get_choice('heartbeep', weights, "normal"))
|
||||
ret.ow_palettes = get_choice('ow_palettes', weights, "default")
|
||||
ret.uw_palettes = get_choice('uw_palettes', weights, "default")
|
||||
ret.hud_palettes = get_choice('hud_palettes', weights, "default")
|
||||
ret.sword_palettes = get_choice('sword_palettes', weights, "default")
|
||||
ret.shield_palettes = get_choice('shield_palettes', weights, "default")
|
||||
ret.link_palettes = get_choice('link_palettes', weights, "default")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
177
NetUtils.py
177
NetUtils.py
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import typing
|
||||
import enum
|
||||
from json import JSONEncoder, JSONDecoder
|
||||
@@ -15,8 +14,10 @@ class JSONMessagePart(typing.TypedDict, total=False):
|
||||
# optional
|
||||
type: str
|
||||
color: str
|
||||
# mainly for items, optional
|
||||
found: bool
|
||||
# owning player for location/item
|
||||
player: int
|
||||
# if type == item indicates item flags
|
||||
flags: int
|
||||
|
||||
|
||||
class ClientStatus(enum.IntEnum):
|
||||
@@ -27,17 +28,57 @@ class ClientStatus(enum.IntEnum):
|
||||
CLIENT_GOAL = 30
|
||||
|
||||
|
||||
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
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for forfeit
|
||||
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
|
||||
|
||||
@staticmethod
|
||||
def from_text(text: str):
|
||||
data = 0
|
||||
if "auto" in text:
|
||||
data |= 0b110
|
||||
elif "goal" in text:
|
||||
data |= 0b010
|
||||
if "enabled" in text:
|
||||
data |= 0b001
|
||||
return Permission(data)
|
||||
|
||||
|
||||
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:
|
||||
@@ -45,7 +86,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
data = obj._asdict()
|
||||
data["class"] = obj.__class__.__name__
|
||||
return data
|
||||
if isinstance(obj, (tuple, list)):
|
||||
if isinstance(obj, (tuple, list, set)):
|
||||
return tuple(_scan_for_TypedTuples(o) for o in obj)
|
||||
if isinstance(obj, dict):
|
||||
return {key: _scan_for_TypedTuples(value) for key, value in obj.items()}
|
||||
@@ -67,9 +108,10 @@ 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,
|
||||
}
|
||||
|
||||
custom_hooks = {
|
||||
"Version": get_any_version
|
||||
@@ -94,60 +136,12 @@ def _object_hook(o: typing.Any) -> typing.Any:
|
||||
decode = JSONDecoder(object_hook=_object_hook).decode
|
||||
|
||||
|
||||
class Node:
|
||||
endpoints: typing.List
|
||||
dumper = staticmethod(encode)
|
||||
loader = staticmethod(decode)
|
||||
|
||||
def __init__(self):
|
||||
self.endpoints = []
|
||||
super(Node, self).__init__()
|
||||
self.log_network = 0
|
||||
|
||||
def broadcast_all(self, msgs):
|
||||
msgs = self.dumper(msgs)
|
||||
for endpoint in self.endpoints:
|
||||
asyncio.create_task(self.send_encoded_msgs(endpoint, msgs))
|
||||
|
||||
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]):
|
||||
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed:
|
||||
return
|
||||
msg = self.dumper(msgs)
|
||||
try:
|
||||
await endpoint.socket.send(msg)
|
||||
except websockets.ConnectionClosed:
|
||||
logging.exception(f"Exception during send_msgs, could not send {msg}")
|
||||
await self.disconnect(endpoint)
|
||||
else:
|
||||
if self.log_network:
|
||||
logging.info(f"Outgoing message: {msg}")
|
||||
|
||||
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str):
|
||||
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed:
|
||||
return
|
||||
try:
|
||||
await endpoint.socket.send(msg)
|
||||
except websockets.ConnectionClosed:
|
||||
logging.exception("Exception during send_msgs")
|
||||
await self.disconnect(endpoint)
|
||||
else:
|
||||
if self.log_network:
|
||||
logging.info(f"Outgoing message: {msg}")
|
||||
|
||||
async def disconnect(self, endpoint):
|
||||
if endpoint in self.endpoints:
|
||||
self.endpoints.remove(endpoint)
|
||||
|
||||
|
||||
class Endpoint:
|
||||
socket: websockets.WebSocketServerProtocol
|
||||
|
||||
def __init__(self, socket):
|
||||
self.socket = socket
|
||||
|
||||
async def disconnect(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class HandlerMeta(type):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
@@ -166,11 +160,11 @@ class HandlerMeta(type):
|
||||
break
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if orig_init:
|
||||
orig_init(self, *args, **kwargs)
|
||||
# turn functions into bound methods
|
||||
self.handlers = {name: method.__get__(self, type(self)) for name, method in
|
||||
handlers.items()}
|
||||
if orig_init:
|
||||
orig_init(self, *args, **kwargs)
|
||||
|
||||
attrs['__init__'] = __init__
|
||||
return super(HandlerMeta, mcs).__new__(mcs, name, bases, attrs)
|
||||
@@ -189,6 +183,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
|
||||
|
||||
@@ -196,13 +205,13 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
return "".join(self.handle_node(section) for section in input_object)
|
||||
|
||||
def handle_node(self, node: JSONMessagePart):
|
||||
type = node.get("type", None)
|
||||
handler = self.handlers.get(type, self.handlers["text"])
|
||||
node_type = node.get("type", None)
|
||||
handler = self.handlers.get(node_type, self.handlers["text"])
|
||||
return handler(node)
|
||||
|
||||
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):
|
||||
@@ -220,11 +229,17 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
return self._handle_color(node)
|
||||
|
||||
def _handle_item_name(self, node: JSONMessagePart):
|
||||
# todo: use a better info source
|
||||
from worlds.alttp.Items import progression_items
|
||||
node["color"] = 'green' if node.get("found", False) else 'cyan'
|
||||
if node["text"] in progression_items:
|
||||
node["color"] += ";white_bg"
|
||||
flags = node.get("flags", 0)
|
||||
if flags == 0:
|
||||
node["color"] = 'cyan'
|
||||
elif flags & 0b001: # advancement
|
||||
node["color"] = 'plum'
|
||||
elif flags & 0b010: # never_exclude
|
||||
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):
|
||||
@@ -233,16 +248,16 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
return self._handle_item_name(node)
|
||||
|
||||
def _handle_location_name(self, node: JSONMessagePart):
|
||||
node["color"] = 'blue_bg;white'
|
||||
node["color"] = 'green'
|
||||
return self._handle_color(node)
|
||||
|
||||
def _handle_location_id(self, node: JSONMessagePart):
|
||||
item_id = int(node["text"])
|
||||
node["text"] = self.ctx.location_name_getter(item_id)
|
||||
return self._handle_item_name(node)
|
||||
return self._handle_location_name(node)
|
||||
|
||||
def _handle_entrance_name(self, node: JSONMessagePart):
|
||||
node["color"] = 'white_bg;black'
|
||||
node["color"] = 'blue'
|
||||
return self._handle_color(node)
|
||||
|
||||
|
||||
@@ -268,6 +283,14 @@ 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, 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:
|
||||
parts.append({"text": str(item_id), "player": player, "type": JSONTypes.location_id, **kwargs})
|
||||
|
||||
|
||||
class Hint(typing.NamedTuple):
|
||||
receiving_player: int
|
||||
finding_player: int
|
||||
@@ -275,13 +298,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):
|
||||
@@ -292,9 +317,9 @@ 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_text(parts, self.item, type="item_id", found=self.found)
|
||||
add_json_item(parts, self.item, self.receiving_player, self.item_flags)
|
||||
add_json_text(parts, " is at ")
|
||||
add_json_text(parts, self.location, type="location_id")
|
||||
add_json_location(parts, self.location, self.finding_player)
|
||||
add_json_text(parts, " in ")
|
||||
add_json_text(parts, self.finding_player, type="player_id")
|
||||
if self.entrance:
|
||||
@@ -302,14 +327,16 @@ class Hint(typing.NamedTuple):
|
||||
add_json_text(parts, self.entrance, type="entrance_name")
|
||||
else:
|
||||
add_json_text(parts, "'s World")
|
||||
add_json_text(parts, ". ")
|
||||
if self.found:
|
||||
add_json_text(parts, ". (found)")
|
||||
add_json_text(parts, "(found)", type="color", color="green")
|
||||
else:
|
||||
add_json_text(parts, ".")
|
||||
add_json_text(parts, "(not found)", type="color", color="red")
|
||||
|
||||
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
|
||||
def local(self):
|
||||
|
||||
246
OoTAdjuster.py
Normal file
246
OoTAdjuster.py
Normal file
@@ -0,0 +1,246 @@
|
||||
import tkinter as tk
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
from itertools import chain
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from Options import Choice, Range, Toggle
|
||||
from worlds.oot import OOTWorld
|
||||
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')
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
|
||||
parser.add_argument('--rom', default='',
|
||||
help='Path to an OoT randomized ROM to adjust.')
|
||||
parser.add_argument('--vanilla_rom', default='',
|
||||
help='Path to a vanilla OoT ROM for patching.')
|
||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||
parser.add_argument('--'+name, default=None,
|
||||
help=option.__doc__)
|
||||
parser.add_argument('--is_glitched', default=False, action='store_true',
|
||||
help='Setting this to true will enable protection on kokiri tunic colors for weirdshot.')
|
||||
parser.add_argument('--deathlink',
|
||||
help='Enable DeathLink system', action='store_true')
|
||||
|
||||
args = parser.parse_args()
|
||||
if not os.path.isfile(args.rom):
|
||||
adjustGUI()
|
||||
else:
|
||||
adjust(args)
|
||||
|
||||
def adjustGUI():
|
||||
from tkinter import Tk, LEFT, BOTTOM, TOP, E, W, \
|
||||
StringVar, IntVar, Checkbutton, Frame, Label, X, Entry, Button, \
|
||||
OptionMenu, filedialog, messagebox, ttk
|
||||
from argparse import Namespace
|
||||
from Main import __version__ as MWVersion
|
||||
|
||||
window = tk.Tk()
|
||||
window.wm_title(f"Archipelago {MWVersion} OoT Adjuster")
|
||||
set_icon(window)
|
||||
|
||||
opts = Namespace()
|
||||
|
||||
# Select ROM
|
||||
romDialogFrame = Frame(window)
|
||||
romLabel = Label(romDialogFrame, text='Rom/patch to adjust')
|
||||
vanillaLabel = Label(romDialogFrame, text='OoT Base Rom')
|
||||
opts.rom = StringVar()
|
||||
opts.vanilla_rom = StringVar(value="The Legend of Zelda - Ocarina of Time.z64")
|
||||
romEntry = Entry(romDialogFrame, textvariable=opts.rom)
|
||||
vanillaEntry = Entry(romDialogFrame, textvariable=opts.vanilla_rom)
|
||||
|
||||
def RomSelect():
|
||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".z64", ".n64", ".apz5")), ("All Files", "*")])
|
||||
opts.rom.set(rom)
|
||||
def VanillaSelect():
|
||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".z64", ".n64")), ("All Files", "*")])
|
||||
opts.vanilla_rom.set(rom)
|
||||
|
||||
romSelectButton = Button(romDialogFrame, text='Select Rom', command=RomSelect)
|
||||
vanillaSelectButton = Button(romDialogFrame, text='Select Rom', command=VanillaSelect)
|
||||
romDialogFrame.pack(side=TOP, expand=True, fill=X)
|
||||
romLabel.pack(side=LEFT)
|
||||
romEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
romSelectButton.pack(side=LEFT)
|
||||
vanillaLabel.pack(side=LEFT)
|
||||
vanillaEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
vanillaSelectButton.pack(side=LEFT)
|
||||
|
||||
# Cosmetic options
|
||||
romSettingsFrame = Frame(window)
|
||||
|
||||
def dropdown_option(type, option_name, row, column):
|
||||
if type == 'cosmetic':
|
||||
option = cosmetic_options[option_name]
|
||||
elif type == 'sfx':
|
||||
option = sfx_options[option_name]
|
||||
optionFrame = Frame(romSettingsFrame)
|
||||
optionFrame.grid(row=row, column=column, sticky=E)
|
||||
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])
|
||||
optionMenu = OptionMenu(optionFrame, getattr(opts, option_name), *option.name_lookup.values())
|
||||
optionMenu.pack(side=LEFT)
|
||||
|
||||
dropdown_option('cosmetic', 'default_targeting', 0, 0)
|
||||
dropdown_option('cosmetic', 'display_dpad', 0, 1)
|
||||
dropdown_option('cosmetic', 'correct_model_colors', 0, 2)
|
||||
dropdown_option('cosmetic', 'background_music', 1, 0)
|
||||
dropdown_option('cosmetic', 'fanfares', 1, 1)
|
||||
dropdown_option('cosmetic', 'ocarina_fanfares', 1, 2)
|
||||
dropdown_option('cosmetic', 'kokiri_color', 2, 0)
|
||||
dropdown_option('cosmetic', 'goron_color', 2, 1)
|
||||
dropdown_option('cosmetic', 'zora_color', 2, 2)
|
||||
dropdown_option('cosmetic', 'silver_gauntlets_color', 3, 0)
|
||||
dropdown_option('cosmetic', 'golden_gauntlets_color', 3, 1)
|
||||
dropdown_option('cosmetic', 'mirror_shield_frame_color', 3, 2)
|
||||
dropdown_option('cosmetic', 'navi_color_default_inner', 4, 0)
|
||||
dropdown_option('cosmetic', 'navi_color_default_outer', 4, 1)
|
||||
dropdown_option('cosmetic', 'navi_color_enemy_inner', 5, 0)
|
||||
dropdown_option('cosmetic', 'navi_color_enemy_outer', 5, 1)
|
||||
dropdown_option('cosmetic', 'navi_color_npc_inner', 6, 0)
|
||||
dropdown_option('cosmetic', 'navi_color_npc_outer', 6, 1)
|
||||
dropdown_option('cosmetic', 'navi_color_prop_inner', 7, 0)
|
||||
dropdown_option('cosmetic', 'navi_color_prop_outer', 7, 1)
|
||||
# sword_trail_duration, 8, 2
|
||||
dropdown_option('cosmetic', 'sword_trail_color_inner', 8, 0)
|
||||
dropdown_option('cosmetic', 'sword_trail_color_outer', 8, 1)
|
||||
dropdown_option('cosmetic', 'bombchu_trail_color_inner', 9, 0)
|
||||
dropdown_option('cosmetic', 'bombchu_trail_color_outer', 9, 1)
|
||||
dropdown_option('cosmetic', 'boomerang_trail_color_inner', 10, 0)
|
||||
dropdown_option('cosmetic', 'boomerang_trail_color_outer', 10, 1)
|
||||
dropdown_option('cosmetic', 'heart_color', 11, 0)
|
||||
dropdown_option('cosmetic', 'magic_color', 12, 0)
|
||||
dropdown_option('cosmetic', 'a_button_color', 11, 1)
|
||||
dropdown_option('cosmetic', 'b_button_color', 11, 2)
|
||||
dropdown_option('cosmetic', 'c_button_color', 12, 1)
|
||||
dropdown_option('cosmetic', 'start_button_color', 12, 2)
|
||||
|
||||
dropdown_option('sfx', 'sfx_navi_overworld', 14, 0)
|
||||
dropdown_option('sfx', 'sfx_navi_enemy', 14, 1)
|
||||
dropdown_option('sfx', 'sfx_low_hp', 14, 2)
|
||||
dropdown_option('sfx', 'sfx_menu_cursor', 15, 0)
|
||||
dropdown_option('sfx', 'sfx_menu_select', 15, 1)
|
||||
dropdown_option('sfx', 'sfx_nightfall', 15, 2)
|
||||
dropdown_option('sfx', 'sfx_horse_neigh', 16, 0)
|
||||
dropdown_option('sfx', 'sfx_hover_boots', 16, 1)
|
||||
dropdown_option('sfx', 'sfx_ocarina', 16, 2)
|
||||
|
||||
# Special cases
|
||||
# Sword trail duration is a range
|
||||
option = cosmetic_options['sword_trail_duration']
|
||||
optionFrame = Frame(romSettingsFrame)
|
||||
optionFrame.grid(row=8, column=2, sticky=E)
|
||||
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)
|
||||
optionMenu = OptionMenu(optionFrame, getattr(opts, 'sword_trail_duration'), *range(4, 21))
|
||||
optionMenu.pack(side=LEFT)
|
||||
|
||||
# Glitched is a checkbox
|
||||
opts.is_glitched = IntVar(value=0)
|
||||
glitched_checkbox = Checkbutton(romSettingsFrame, text="Glitched Logic?", variable=opts.is_glitched)
|
||||
glitched_checkbox.grid(row=17, column=0, sticky=W)
|
||||
|
||||
# Deathlink is a checkbox
|
||||
opts.deathlink = IntVar(value=0)
|
||||
deathlink_checkbox = Checkbutton(romSettingsFrame, text="DeathLink (Team Deaths)", variable=opts.deathlink)
|
||||
deathlink_checkbox.grid(row=17, column=1, sticky=W)
|
||||
|
||||
romSettingsFrame.pack(side=TOP)
|
||||
|
||||
def adjustRom():
|
||||
try:
|
||||
guiargs = Namespace()
|
||||
options = vars(opts)
|
||||
for o in options:
|
||||
result = options[o].get()
|
||||
if result == 'true':
|
||||
result = True
|
||||
if result == 'false':
|
||||
result = False
|
||||
setattr(guiargs, o, result)
|
||||
guiargs.sword_trail_duration = int(guiargs.sword_trail_duration)
|
||||
path = adjust(guiargs)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
messagebox.showerror(title="Error while adjusting Rom", message=str(e))
|
||||
else:
|
||||
messagebox.showinfo(title="Success", message=f"Rom patched successfully to {path}")
|
||||
|
||||
# Adjust button
|
||||
bottomFrame = Frame(window)
|
||||
adjustButton = Button(bottomFrame, text='Adjust Rom', command=adjustRom)
|
||||
adjustButton.pack(side=BOTTOM, padx=(5, 5))
|
||||
bottomFrame.pack(side=BOTTOM, pady=(5, 5))
|
||||
|
||||
window.mainloop()
|
||||
|
||||
def set_icon(window):
|
||||
logo = tk.PhotoImage(file=local_path('data', 'icon.png'))
|
||||
window.tk.call('wm', 'iconphoto', window._w, logo)
|
||||
|
||||
def adjust(args):
|
||||
# Create a fake world and OOTWorld to use as a base
|
||||
world = MultiWorld(1)
|
||||
world.slot_seeds = {1: random}
|
||||
ootworld = OOTWorld(world, 1)
|
||||
# Set options in the fake OOTWorld
|
||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||
result = getattr(args, name, None)
|
||||
if result is None:
|
||||
if issubclass(option, Choice):
|
||||
result = option.name_lookup[option.default]
|
||||
elif issubclass(option, Range) or issubclass(option, Toggle):
|
||||
result = option.default
|
||||
else:
|
||||
raise Exception("Unsupported option type")
|
||||
setattr(ootworld, name, result)
|
||||
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)
|
||||
# Patch file
|
||||
apply_patch_file(rom, args.rom)
|
||||
else:
|
||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
|
||||
# Call patch_cosmetics
|
||||
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__':
|
||||
main()
|
||||
287
OoTClient.py
Normal file
287
OoTClient.py
Normal file
@@ -0,0 +1,287 @@
|
||||
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"]
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
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
|
||||
|
||||
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 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
|
||||
if payload['deathlinkActive'] and not ctx.deathlink_enabled:
|
||||
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 five fields:
|
||||
# 1. str: player name (always)
|
||||
# 2. bool: deathlink active (always)
|
||||
# 3. dict[str, bool]: checked locations
|
||||
# 4. bool: whether Link is currently at 0 HP
|
||||
# 5. bool: whether the game currently registers as complete
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||
data_decoded = json.loads(data.decode())
|
||||
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)
|
||||
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:
|
||||
input_task = None
|
||||
from kvui import OoTManager
|
||||
ctx.ui = OoTManager(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.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
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
loop.close()
|
||||
colorama.deinit()
|
||||
409
Options.py
409
Options.py
@@ -2,43 +2,102 @@ from __future__ import annotations
|
||||
import typing
|
||||
import random
|
||||
|
||||
from schema import Schema, And, Or
|
||||
|
||||
|
||||
class AssembleOptions(type):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
options = attrs["options"] = {}
|
||||
name_lookup = attrs["name_lookup"] = {}
|
||||
# merge parent class options
|
||||
for base in bases:
|
||||
if hasattr(base, "options"):
|
||||
if getattr(base, "options", None):
|
||||
options.update(base.options)
|
||||
name_lookup.update(name_lookup)
|
||||
name_lookup.update(base.name_lookup)
|
||||
new_options = {name[7:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("option_")}
|
||||
|
||||
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_")})
|
||||
|
||||
# auto-validate schema on __init__
|
||||
if "schema" in attrs.keys():
|
||||
|
||||
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)
|
||||
|
||||
cls.__init__ = meta__init__
|
||||
return cls
|
||||
|
||||
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
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}({self.get_option_name()})"
|
||||
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
|
||||
# Handled in get_option_name()
|
||||
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):
|
||||
return hash(self.value)
|
||||
|
||||
def get_option_name(self):
|
||||
@property
|
||||
def current_key(self) -> str:
|
||||
return self.name_lookup[self.value]
|
||||
|
||||
def __int__(self):
|
||||
def get_current_option_name(self) -> str:
|
||||
"""For display purposes."""
|
||||
return self.get_option_name(self.value)
|
||||
|
||||
@classmethod
|
||||
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) -> T:
|
||||
return self.value
|
||||
|
||||
def __bool__(self):
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.value)
|
||||
|
||||
@classmethod
|
||||
@@ -46,17 +105,20 @@ class Option(metaclass=AssembleOptions):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Toggle(Option):
|
||||
class Toggle(Option[int]):
|
||||
option_false = 0
|
||||
option_true = 1
|
||||
default = 0
|
||||
|
||||
def __init__(self, value: int):
|
||||
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)
|
||||
@@ -86,20 +148,30 @@ class Toggle(Option):
|
||||
def __int__(self):
|
||||
return int(self.value)
|
||||
|
||||
def get_option_name(self):
|
||||
return bool(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):
|
||||
|
||||
class Choice(Option[int]):
|
||||
auto_display_name = True
|
||||
|
||||
def __init__(self, value: int):
|
||||
self.value: int = value
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> Choice:
|
||||
for optionname, value in cls.options.items():
|
||||
if optionname == text.lower():
|
||||
text = text.lower()
|
||||
if text == "random":
|
||||
return cls(random.choice(list(cls.name_lookup)))
|
||||
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__}", '
|
||||
@@ -111,8 +183,40 @@ class Choice(Option):
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, self.__class__):
|
||||
return other.value == self.value
|
||||
elif isinstance(other, str):
|
||||
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, 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)
|
||||
else:
|
||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||
|
||||
class Range(Option, int):
|
||||
def __ne__(self, other):
|
||||
if isinstance(other, self.__class__):
|
||||
return other.value != self.value
|
||||
elif isinstance(other, str):
|
||||
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, 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)
|
||||
elif other is None:
|
||||
return False
|
||||
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], int):
|
||||
range_start = 0
|
||||
range_end = 1
|
||||
|
||||
@@ -133,8 +237,29 @@ class Range(Option, int):
|
||||
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:
|
||||
elif text.startswith("random-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(int(round(random.triangular(random_range[0], random_range[1], random_range[0]))))
|
||||
elif text.startswith("random-range-middle"):
|
||||
return cls(int(round(random.triangular(random_range[0], random_range[1]))))
|
||||
elif text.startswith("random-range-high"):
|
||||
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[1]))))
|
||||
else:
|
||||
return cls(int(round(random.randint(random_range[0], random_range[1]))))
|
||||
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. Acceptable values are: random, random-high, random-middle, random-low, random-range-low-<min>-<max>, random-range-middle-<min>-<max>, random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||
return cls(int(text))
|
||||
|
||||
@classmethod
|
||||
@@ -143,68 +268,262 @@ class Range(Option, int):
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self):
|
||||
return str(self.value)
|
||||
def get_option_name(self, value):
|
||||
return str(value)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
|
||||
class OptionNameSet(Option):
|
||||
default = frozenset()
|
||||
|
||||
def __init__(self, value: typing.Set[str]):
|
||||
self.value: typing.Set[str] = value
|
||||
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 from_text(cls, text: str) -> OptionNameSet:
|
||||
return cls({option.strip() for option in text.split(",")})
|
||||
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}.")
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any) -> OptionNameSet:
|
||||
if type(data) == set:
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
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:
|
||||
raise Exception(f"Item {item_name} from option {self} "
|
||||
f"is not a valid item name from {world.game}")
|
||||
elif self.verify_location_name:
|
||||
for location_name in self.value:
|
||||
if location_name not in world.location_names:
|
||||
raise Exception(f"Location {location_name} from option {self} "
|
||||
f"is not a valid location name from {world.game}")
|
||||
|
||||
|
||||
class OptionDict(Option):
|
||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
||||
default = {}
|
||||
supports_weighting = False
|
||||
|
||||
def __init__(self, value: typing.Dict[str, typing.Any]):
|
||||
self.value: typing.Dict[str, typing.Any] = value
|
||||
self.value = value
|
||||
|
||||
@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)}")
|
||||
|
||||
def get_option_name(self):
|
||||
return str(self.value)
|
||||
def get_option_name(self, value):
|
||||
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self.value
|
||||
|
||||
|
||||
class ItemDict(OptionDict):
|
||||
verify_item_name = True
|
||||
|
||||
def __init__(self, value: typing.Dict[str, int]):
|
||||
if any(item_count < 1 for item_count in value.values()):
|
||||
raise Exception("Cannot have non-positive item counts.")
|
||||
super(ItemDict, self).__init__(value)
|
||||
|
||||
|
||||
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
default = []
|
||||
supports_weighting = False
|
||||
|
||||
def __init__(self, value: typing.List[typing.Any]):
|
||||
self.value = value or []
|
||||
super(OptionList, self).__init__()
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str):
|
||||
return cls([option.strip() for option in text.split(",")])
|
||||
|
||||
@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))
|
||||
|
||||
def get_option_name(self, value):
|
||||
return ", ".join(map(str, value))
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self.value
|
||||
|
||||
|
||||
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||
default = frozenset()
|
||||
supports_weighting = False
|
||||
|
||||
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
|
||||
self.value = set(value)
|
||||
super(OptionSet, self).__init__()
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str):
|
||||
return cls([option.strip() for option in text.split(",")])
|
||||
|
||||
@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(sorted(value))
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self.value
|
||||
|
||||
|
||||
local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
|
||||
|
||||
|
||||
class Accessibility(Choice):
|
||||
"""Set rules for reachability of your items/locations.
|
||||
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_beatable = 2
|
||||
option_minimal = 2
|
||||
alias_none = 2
|
||||
default = 1
|
||||
|
||||
|
||||
class ProgressionBalancing(DefaultOnToggle):
|
||||
"""A system that moves progression earlier, to try and prevent the player from getting stuck and bored early."""
|
||||
display_name = "Progression Balancing"
|
||||
|
||||
|
||||
common_options = {
|
||||
"progression_balancing": ProgressionBalancing,
|
||||
"accessibility": Accessibility
|
||||
}
|
||||
|
||||
|
||||
class ItemSet(OptionSet):
|
||||
verify_item_name = True
|
||||
convert_name_groups = True
|
||||
|
||||
|
||||
class LocalItems(ItemSet):
|
||||
"""Forces these items to be in their native world."""
|
||||
display_name = "Local Items"
|
||||
|
||||
|
||||
class NonLocalItems(ItemSet):
|
||||
"""Forces these items to be outside their native world."""
|
||||
display_name = "Not Local Items"
|
||||
|
||||
|
||||
class StartInventory(ItemDict):
|
||||
"""Start with these items."""
|
||||
verify_item_name = True
|
||||
display_name = "Start Inventory"
|
||||
|
||||
|
||||
class StartHints(ItemSet):
|
||||
"""Start with these item's locations prefilled into the !hint command."""
|
||||
display_name = "Start Hints"
|
||||
|
||||
|
||||
class StartLocationHints(OptionSet):
|
||||
"""Start with these locations and their item prefilled into the !hint command"""
|
||||
display_name = "Start Location Hints"
|
||||
|
||||
|
||||
class ExcludeLocations(OptionSet):
|
||||
"""Prevent these locations from having an important item"""
|
||||
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."""
|
||||
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)],
|
||||
"replacement_item": Or(And(str, len), None)
|
||||
}
|
||||
])
|
||||
|
||||
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"])
|
||||
for item_name in link["item_pool"]:
|
||||
if item_name not in world.item_names and item_name not in world.item_name_groups:
|
||||
raise Exception(f"Item {item_name} from item link {link} "
|
||||
f"is not a valid item name from {world.game}")
|
||||
if link["replacement_item"] and link["replacement_item"] not in world.item_names:
|
||||
raise Exception(f"Item {link['replacement_item']} from item link {link} "
|
||||
f"is not a valid item name from {world.game}")
|
||||
|
||||
|
||||
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,
|
||||
"priority_locations": PriorityLocations,
|
||||
"item_links": ItemLinks
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
from worlds.alttp.Options import Logic
|
||||
import argparse
|
||||
mapshuffle = Toggle
|
||||
compassshuffle = Toggle
|
||||
keyshuffle = Toggle
|
||||
bigkeyshuffle = Toggle
|
||||
|
||||
map_shuffle = Toggle
|
||||
compass_shuffle = Toggle
|
||||
key_shuffle = Toggle
|
||||
big_key_shuffle = Toggle
|
||||
hints = Toggle
|
||||
test = argparse.Namespace()
|
||||
test.logic = Logic.from_text("no_logic")
|
||||
test.mapshuffle = mapshuffle.from_text("ON")
|
||||
test.map_shuffle = map_shuffle.from_text("ON")
|
||||
test.hints = hints.from_text('OFF')
|
||||
try:
|
||||
test.logic = Logic.from_text("overworld_glitches_typo")
|
||||
@@ -214,7 +533,7 @@ if __name__ == "__main__":
|
||||
test.logic_owg = Logic.from_text("owg")
|
||||
except KeyError as e:
|
||||
print(e)
|
||||
if test.mapshuffle:
|
||||
print("Mapshuffle is on")
|
||||
if test.map_shuffle:
|
||||
print("map_shuffle is on")
|
||||
print(f"Hints are {bool(test.hints)}")
|
||||
print(test)
|
||||
|
||||
357
Patch.py
357
Patch.py
@@ -1,97 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import json
|
||||
import bsdiff4
|
||||
import yaml
|
||||
import os
|
||||
import lzma
|
||||
import hashlib
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import zipfile
|
||||
import sys
|
||||
from typing import Tuple, Optional
|
||||
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
|
||||
|
||||
import Utils
|
||||
from worlds.alttp.Rom import JAP10HASH
|
||||
|
||||
current_patch_version = 2
|
||||
current_patch_version = 4
|
||||
|
||||
|
||||
def get_base_rom_path(file_name: str = "") -> str:
|
||||
options = Utils.get_options()
|
||||
if not file_name:
|
||||
file_name = options["lttp_options"]["rom_file"]
|
||||
if not os.path.exists(file_name):
|
||||
file_name = Utils.local_path(file_name)
|
||||
return file_name
|
||||
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
|
||||
|
||||
|
||||
def get_base_rom_bytes(file_name: str = "") -> bytes:
|
||||
from worlds.alttp.Rom import read_rom
|
||||
base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None)
|
||||
if not base_rom_bytes:
|
||||
file_name = get_base_rom_path(file_name)
|
||||
base_rom_bytes = bytes(read_rom(open(file_name, "rb")))
|
||||
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
|
||||
|
||||
basemd5 = hashlib.md5()
|
||||
basemd5.update(base_rom_bytes)
|
||||
if JAP10HASH != basemd5.hexdigest():
|
||||
raise Exception('Supplied Base Rom does not match known MD5 for JAP(1.0) release. '
|
||||
'Get the correct game and version, then dump it')
|
||||
get_base_rom_bytes.base_rom_bytes = base_rom_bytes
|
||||
return base_rom_bytes
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
def generate_yaml(patch: bytes, metadata: Optional[dict] = None) -> bytes:
|
||||
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"
|
||||
GAME_SMZ3 = "SMZ3"
|
||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3"}
|
||||
|
||||
preferred_endings = {
|
||||
GAME_ALTTP: "apbp",
|
||||
GAME_SM: "apm3",
|
||||
GAME_SOE: "apsoe",
|
||||
GAME_SMZ3: "apsmz"
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
elif game == GAME_SM:
|
||||
from worlds.sm.Rom import JAP10HASH as HASH
|
||||
elif game == GAME_SOE:
|
||||
from worlds.soe.Patch import USHASH as HASH
|
||||
elif game == GAME_SMZ3:
|
||||
from worlds.alttp.Rom import JAP10HASH as ALTTPHASH
|
||||
from worlds.sm.Rom import JAP10HASH as SMHASH
|
||||
HASH = ALTTPHASH + SMHASH
|
||||
else:
|
||||
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
||||
|
||||
patch = yaml.dump({"meta": metadata,
|
||||
"patch": patch,
|
||||
"game": "A Link to the Past",
|
||||
"game": game,
|
||||
# minimum version of patch system expected for patching to be successful
|
||||
"compatible_version": 1,
|
||||
"compatible_version": 3,
|
||||
"version": current_patch_version,
|
||||
"base_checksum": JAP10HASH})
|
||||
"base_checksum": HASH})
|
||||
return patch.encode(encoding="utf-8-sig")
|
||||
|
||||
|
||||
def generate_patch(rom: bytes, metadata: Optional[dict] = None) -> bytes:
|
||||
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
patch = bsdiff4.diff(get_base_rom_bytes(), rom)
|
||||
return generate_yaml(patch, metadata)
|
||||
patch = bsdiff4.diff(get_base_rom_data(game), rom)
|
||||
return generate_yaml(patch, metadata, game)
|
||||
|
||||
|
||||
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
|
||||
player: int = 0, player_name: str = "") -> str:
|
||||
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||
player: int = 0, player_name: str = "", game: str = GAME_ALTTP) -> str:
|
||||
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||
"player_id": player,
|
||||
"player_name": player_name}
|
||||
bytes = generate_patch(load_bytes(rom_file_to_patch),
|
||||
meta)
|
||||
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + ".apbp"
|
||||
meta,
|
||||
game)
|
||||
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
|
||||
".apbp" if game == GAME_ALTTP else ".apsmz" if game == GAME_SMZ3 else ".apm3")
|
||||
write_lzma(bytes, target)
|
||||
return target
|
||||
|
||||
|
||||
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
|
||||
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
|
||||
game_name = data["game"]
|
||||
if not ignore_version and data["compatible_version"] > current_patch_version:
|
||||
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
|
||||
patched_data = bsdiff4.patch(get_base_rom_bytes(), data["patch"])
|
||||
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
|
||||
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
|
||||
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
|
||||
target = os.path.splitext(patch_file)[0] + ".sfc"
|
||||
return data["meta"], target, patched_data
|
||||
|
||||
|
||||
def get_base_rom_data(game: str):
|
||||
if game == GAME_ALTTP:
|
||||
from worlds.alttp.Rom import get_base_rom_bytes
|
||||
elif game == "alttp": # old version for A Link to the Past
|
||||
from worlds.alttp.Rom import get_base_rom_bytes
|
||||
elif game == GAME_SM:
|
||||
from worlds.sm.Rom import get_base_rom_bytes
|
||||
elif game == GAME_SOE:
|
||||
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
|
||||
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:
|
||||
data = Utils.parse_yaml(lzma.decompress(patch_data).decode("utf-8-sig"))
|
||||
data["meta"]["server"] = server
|
||||
bytes = generate_yaml(data["patch"], data["meta"])
|
||||
bytes = generate_yaml(data["patch"], data["meta"], data["game"])
|
||||
return lzma.compress(bytes)
|
||||
|
||||
|
||||
@@ -105,6 +280,14 @@ def write_lzma(data: bytes, path: str):
|
||||
f.write(data)
|
||||
|
||||
|
||||
def read_rom(stream, strip_header=True) -> bytearray:
|
||||
"""Reads rom into bytearray and optionally strips off any smc header"""
|
||||
buffer = bytearray(stream.read())
|
||||
if strip_header and len(buffer) % 0x400 == 0x200:
|
||||
return buffer[0x200:]
|
||||
return buffer
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = Utils.get_public_ipv4()
|
||||
options = Utils.get_options()['server_options']
|
||||
@@ -125,10 +308,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)
|
||||
@@ -136,25 +372,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(".apm3"):
|
||||
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(".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(".zip"):
|
||||
print(f"Updating host in patch files contained in {rom}")
|
||||
@@ -162,7 +393,7 @@ if __name__ == "__main__":
|
||||
|
||||
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
||||
data = zfr.read(zfinfo)
|
||||
if zfinfo.filename.endswith(".apbp"):
|
||||
if zfinfo.filename.endswith(".apbp") or zfinfo.filename.endswith(".apm3"):
|
||||
data = update_patch_data(data, server)
|
||||
with ziplock:
|
||||
zfw.writestr(zfinfo, data)
|
||||
|
||||
26
README.md
26
README.md
@@ -6,8 +6,25 @@ Currently, the following games are supported:
|
||||
* The Legend of Zelda: A Link to the Past
|
||||
* Factorio
|
||||
* Minecraft
|
||||
* Subnautica
|
||||
* Slay the Spire
|
||||
* Risk of Rain 2
|
||||
* The Legend of Zelda: Ocarina of Time
|
||||
* 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
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial).
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
windows binaries.
|
||||
|
||||
@@ -29,13 +46,14 @@ 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/Berserker66/MultiWorld-Utilities/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 wiki page on [running Archipelago from source](https://github.com/ArchipelagoMW/Archipelago/wiki/Running-from-source).
|
||||
|
||||
## 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.
|
||||
|
||||
* [z3randomizer](https://github.com/CaitSith2/z3randomizer)
|
||||
* [z3randomizer](https://github.com/ArchipelagoMW/z3randomizer)
|
||||
* [Enemizer](https://github.com/Ijwu/Enemizer)
|
||||
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
|
||||
|
||||
## Contributing
|
||||
Contributions are welcome. We have a few asks of any new contributors.
|
||||
@@ -45,6 +63,8 @@ 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 please see the docs folder for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
|
||||
|
||||
## 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:
|
||||
|
||||
|
||||
1447
SNIClient.py
Normal file
1447
SNIClient.py
Normal file
File diff suppressed because it is too large
Load Diff
366
Utils.py
366
Utils.py
@@ -1,9 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import typing
|
||||
import builtins
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import pickle
|
||||
import functools
|
||||
import io
|
||||
import collections
|
||||
import importlib
|
||||
import logging
|
||||
from tkinter import Tk
|
||||
|
||||
|
||||
def tuplize_version(version: str) -> typing.Tuple[int, ...]:
|
||||
def tuplize_version(version: str) -> Version:
|
||||
return Version(*(int(piece, 10) for piece in version.split(".")))
|
||||
|
||||
|
||||
@@ -13,19 +25,10 @@ class Version(typing.NamedTuple):
|
||||
build: int
|
||||
|
||||
|
||||
__version__ = "0.1.4"
|
||||
__version__ = "0.3.1"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
import builtins
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import pickle
|
||||
import functools
|
||||
import io
|
||||
import collections
|
||||
|
||||
from yaml import load, dump, safe_load
|
||||
from yaml import load, dump, SafeLoader
|
||||
|
||||
try:
|
||||
from yaml import CLoader as Loader
|
||||
@@ -51,24 +54,6 @@ def snes_to_pc(value):
|
||||
return ((value & 0x7F0000) >> 1) | (value & 0x7FFF)
|
||||
|
||||
|
||||
def parse_player_names(names, players, teams):
|
||||
names = tuple(n for n in (n.strip() for n in names.split(",")) if n)
|
||||
if len(names) != len(set(names)):
|
||||
name_counter = collections.Counter(names)
|
||||
raise ValueError(f"Duplicate Player names is not supported, "
|
||||
f'found multiple "{name_counter.most_common(1)[0][0]}".')
|
||||
ret = []
|
||||
while names or len(ret) < teams:
|
||||
team = [n[:16] for n in names[:players]]
|
||||
# 16 bytes in rom per player, which will map to more in unicode, but those characters later get filtered
|
||||
while len(team) != players:
|
||||
team.append(f"Player{len(team) + 1}")
|
||||
ret.append(team)
|
||||
|
||||
names = names[players:]
|
||||
return ret
|
||||
|
||||
|
||||
def cache_argsless(function):
|
||||
if function.__code__.co_argcount:
|
||||
raise Exception("Can only cache 0 argument functions with this cache.")
|
||||
@@ -84,15 +69,15 @@ def cache_argsless(function):
|
||||
return _wrap
|
||||
|
||||
|
||||
def is_bundled() -> bool:
|
||||
def is_frozen() -> bool:
|
||||
return getattr(sys, 'frozen', False)
|
||||
|
||||
|
||||
def local_path(*path):
|
||||
if local_path.cached_path:
|
||||
return os.path.join(local_path.cached_path, *path)
|
||||
|
||||
elif is_bundled():
|
||||
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
|
||||
local_path.cached_path = sys._MEIPASS # pylint: disable=protected-access,no-member
|
||||
@@ -111,21 +96,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)
|
||||
@@ -134,38 +145,62 @@ 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)
|
||||
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
|
||||
|
||||
|
||||
def get_cert_none_ssl_context():
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_public_ipv4() -> str:
|
||||
import socket
|
||||
import urllib.request
|
||||
import logging
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
ctx = get_cert_none_ssl_context()
|
||||
try:
|
||||
ip = urllib.request.urlopen('https://checkip.amazonaws.com/').read().decode('utf8').strip()
|
||||
ip = urllib.request.urlopen('https://checkip.amazonaws.com/', context=ctx).read().decode('utf8').strip()
|
||||
except Exception as e:
|
||||
try:
|
||||
ip = urllib.request.urlopen('https://v4.ident.me').read().decode('utf8').strip()
|
||||
ip = urllib.request.urlopen('https://v4.ident.me', context=ctx).read().decode('utf8').strip()
|
||||
except:
|
||||
logging.exception(e)
|
||||
pass # we could be offline, in a local game, so no point in erroring out
|
||||
return ip
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_public_ipv6() -> str:
|
||||
import socket
|
||||
import urllib.request
|
||||
import logging
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
ctx = get_cert_none_ssl_context()
|
||||
try:
|
||||
ip = urllib.request.urlopen('https://v6.ident.me').read().decode('utf8').strip()
|
||||
ip = urllib.request.urlopen('https://v6.ident.me', context=ctx).read().decode('utf8').strip()
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
pass # we could be offline, in a local game, or ipv6 may not be available
|
||||
return ip
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_default_options() -> dict:
|
||||
# Refer to host.yaml for comments as to what all these options mean.
|
||||
@@ -174,7 +209,15 @@ 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",
|
||||
"sni": "SNI",
|
||||
"rom_start": True,
|
||||
},
|
||||
"soe_options": {
|
||||
"rom_file": "Secret of Evermore (USA).sfc",
|
||||
},
|
||||
"lttp_options": {
|
||||
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
||||
@@ -195,73 +238,63 @@ def get_default_options() -> dict:
|
||||
"location_check_points": 1,
|
||||
"hint_cost": 10,
|
||||
"forfeit_mode": "goal",
|
||||
"collect_mode": "disabled",
|
||||
"remaining_mode": "goal",
|
||||
"auto_shutdown": 0,
|
||||
"compatibility": 2,
|
||||
"log_network": 0
|
||||
},
|
||||
"multi_mystery_options": {
|
||||
"generator": {
|
||||
"teams": 1,
|
||||
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
|
||||
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core.exe"),
|
||||
"player_files_path": "Players",
|
||||
"players": 0,
|
||||
"weights_file_path": "weights.yaml",
|
||||
"meta_file_path": "meta.yaml",
|
||||
"pre_roll": False,
|
||||
"create_spoiler": 1,
|
||||
"zip_roms": 0,
|
||||
"zip_diffs": 2,
|
||||
"zip_apmcs": 1,
|
||||
"zip_spoiler": 0,
|
||||
"zip_multidata": 1,
|
||||
"zip_format": 1,
|
||||
"spoiler": 2,
|
||||
"glitch_triforce_room": 1,
|
||||
"race": 0,
|
||||
"cpu_threads": 0,
|
||||
"max_attempts": 0,
|
||||
"take_first_working": False,
|
||||
"keep_all_seeds": False,
|
||||
"log_output_path": "Output Logs",
|
||||
"log_level": None,
|
||||
"plando_options": "bosses",
|
||||
},
|
||||
"minecraft_options": {
|
||||
"forge_directory": "Minecraft Forge server",
|
||||
"max_heap_size": "2G"
|
||||
},
|
||||
"oot_options": {
|
||||
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
|
||||
|
||||
blacklisted_options = {"multi_mystery_options.cpu_threads",
|
||||
"multi_mystery_options.max_attempts",
|
||||
"multi_mystery_options.take_first_working",
|
||||
"multi_mystery_options.keep_all_seeds",
|
||||
"multi_mystery_options.log_output_path",
|
||||
"multi_mystery_options.log_level"}
|
||||
|
||||
|
||||
def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||
import logging
|
||||
for key, value in src.items():
|
||||
new_keys = keys.copy()
|
||||
new_keys.append(key)
|
||||
option_name = '.'.join(new_keys)
|
||||
if key not in dest:
|
||||
dest[key] = value
|
||||
if filename.endswith("options.yaml") and option_name not in blacklisted_options:
|
||||
if filename.endswith("options.yaml"):
|
||||
logging.info(f"Warning: {filename} is missing {option_name}")
|
||||
elif isinstance(value, dict):
|
||||
if not isinstance(dest.get(key, None), dict):
|
||||
if filename.endswith("options.yaml") and option_name not in blacklisted_options:
|
||||
if filename.endswith("options.yaml"):
|
||||
logging.info(f"Warning: {filename} has {option_name}, but it is not a dictionary. overwriting.")
|
||||
dest[key] = value
|
||||
else:
|
||||
dest[key] = update_options(value, dest[key], filename, new_keys)
|
||||
return dest
|
||||
|
||||
|
||||
@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):
|
||||
@@ -271,7 +304,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
|
||||
|
||||
|
||||
@@ -286,7 +319,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
|
||||
@@ -298,14 +331,13 @@ 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:
|
||||
with open(path, "r") as f:
|
||||
storage = unsafe_parse_yaml(f.read())
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.debug(f"Could not read store: {e}")
|
||||
if storage is None:
|
||||
storage = {}
|
||||
@@ -313,63 +345,10 @@ def persistent_load() -> typing.Dict[dict]:
|
||||
return storage
|
||||
|
||||
|
||||
def get_adjuster_settings(romfile: str) -> 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", {})
|
||||
def get_adjuster_settings(gameName: str):
|
||||
adjuster_settings = persistent_load().get("adjuster", {}).get(gameName, {})
|
||||
return adjuster_settings
|
||||
|
||||
if adjuster_settings:
|
||||
import pprint
|
||||
import Patch
|
||||
adjuster_settings.rom = romfile
|
||||
adjuster_settings.baserom = Patch.get_base_rom_path()
|
||||
adjuster_settings.world = None
|
||||
whitelist = {"disablemusic", "fastmenu", "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
|
||||
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
|
||||
import logging
|
||||
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
|
||||
|
||||
@cache_argsless
|
||||
def get_unique_identifier():
|
||||
@@ -390,16 +369,28 @@ safe_builtins = {
|
||||
|
||||
|
||||
class RestrictedUnpickler(pickle.Unpickler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
|
||||
self.options_module = importlib.import_module("Options")
|
||||
self.net_utils_module = importlib.import_module("NetUtils")
|
||||
self.generic_properties_module = importlib.import_module("worlds.generic")
|
||||
|
||||
def find_class(self, module, name):
|
||||
if module == "builtins" and name in safe_builtins:
|
||||
return getattr(builtins, name)
|
||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint"}:
|
||||
import NetUtils
|
||||
return getattr(NetUtils, name)
|
||||
if module == "Options":
|
||||
import Options
|
||||
obj = getattr(Options, name)
|
||||
if issubclass(obj, Options.Option):
|
||||
# used by MultiServer -> savegame/multidata
|
||||
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"}:
|
||||
return getattr(self.generic_properties_module, name)
|
||||
if module.endswith("Options"):
|
||||
if module == "Options":
|
||||
mod = self.options_module
|
||||
else:
|
||||
mod = importlib.import_module(module)
|
||||
obj = getattr(mod, name)
|
||||
if issubclass(obj, self.options_module.Option):
|
||||
return obj
|
||||
# Forbid everything else.
|
||||
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
|
||||
@@ -414,4 +405,85 @@ def restricted_loads(s):
|
||||
class KeyedDefaultDict(collections.defaultdict):
|
||||
def __missing__(self, key):
|
||||
self[key] = value = self.default_factory(key)
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def get_text_between(text: str, start: str, end: str) -> str:
|
||||
return text[text.index(start) + len(start): text.rindex(end)]
|
||||
|
||||
|
||||
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
|
||||
|
||||
|
||||
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
|
||||
log_format: str = "[%(name)s]: %(message)s", exception_logger: str = ""):
|
||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||
log_folder = user_path("logs")
|
||||
os.makedirs(log_folder, exist_ok=True)
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
handler.close()
|
||||
root_logger.setLevel(loglevel)
|
||||
file_handler = logging.FileHandler(
|
||||
os.path.join(log_folder, f"{name}.txt"),
|
||||
write_mode,
|
||||
encoding="utf-8-sig")
|
||||
file_handler.setFormatter(logging.Formatter(log_format))
|
||||
root_logger.addHandler(file_handler)
|
||||
if sys.stdout:
|
||||
root_logger.addHandler(
|
||||
logging.StreamHandler(sys.stdout)
|
||||
)
|
||||
|
||||
# Relay unhandled exceptions to logger.
|
||||
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
|
||||
orig_hook = sys.excepthook
|
||||
|
||||
def handle_exception(exc_type, exc_value, exc_traceback):
|
||||
if issubclass(exc_type, KeyboardInterrupt):
|
||||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||
return
|
||||
logging.getLogger(exception_logger).exception("Uncaught exception",
|
||||
exc_info=(exc_type, exc_value, exc_traceback))
|
||||
return orig_hook(exc_type, exc_value, exc_traceback)
|
||||
|
||||
handle_exception._wrapped = True
|
||||
|
||||
sys.excepthook = handle_exception
|
||||
|
||||
|
||||
def stream_input(stream, queue):
|
||||
def queuer():
|
||||
while 1:
|
||||
text = stream.readline().strip()
|
||||
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 format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")):
|
||||
n = 0
|
||||
|
||||
while value > power:
|
||||
value /= power
|
||||
n += 1
|
||||
if type(value) == int:
|
||||
return f"{value} {power_labels[n]}"
|
||||
else:
|
||||
return f"{value:0.3f} {power_labels[n]}"
|
||||
|
||||
30
WebHost.py
30
WebHost.py
@@ -3,16 +3,26 @@ import multiprocessing
|
||||
import logging
|
||||
|
||||
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
|
||||
|
||||
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():
|
||||
@@ -26,13 +36,31 @@ def get_app():
|
||||
return app
|
||||
|
||||
|
||||
def create_ordered_tutorials_file():
|
||||
import json
|
||||
with open(os.path.join("WebHostLib", "static", "assets", "tutorial", "tutorials.json")) as source:
|
||||
data = json.load(source)
|
||||
data = sorted(data, key=lambda entry: entry["gameTitle"].lower())
|
||||
with open(os.path.join("WebHostLib", "static", "generated", "tutorials.json"), "w") as target:
|
||||
json.dump(data, target)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
multiprocessing.set_start_method('spawn')
|
||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||
try:
|
||||
update_sprites_lttp()
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
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)
|
||||
|
||||
@@ -8,6 +8,7 @@ from pony.flask import Pony
|
||||
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||
from flask_caching import Cache
|
||||
from flask_compress import Compress
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
from .models import *
|
||||
|
||||
@@ -21,13 +22,14 @@ 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
|
||||
app.config['MAX_CONTENT_LENGTH'] = 4 * 1024 * 1024 # 4 megabyte limit
|
||||
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
||||
# if you want to deploy, make sure you have a non-guessable secret key
|
||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the webthread
|
||||
@@ -68,6 +70,12 @@ app.url_map.converters["suuid"] = B64UUIDConverter
|
||||
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||
|
||||
|
||||
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():
|
||||
session.permanent = True # technically 31 days after the last visit
|
||||
@@ -81,67 +89,60 @@ def page_not_found(err):
|
||||
return render_template('404.html'), 404
|
||||
|
||||
|
||||
games_list = {
|
||||
"zelda3": ("The Legend of Zelda: A Link to the Past",
|
||||
"""
|
||||
The Legend of Zelda: A Link to the Past is an action/adventure game. Take on the role of Link,
|
||||
a boy who is destined to save the land of Hyrule. Delve through three palaces and nine dungeons on
|
||||
your quest to rescue the descendents of the seven wise men and defeat the evil Ganon!"""),
|
||||
"factorio": ("Factorio",
|
||||
"""
|
||||
Factorio is a game about automation. You play as an engineer who has crash landed on the planet
|
||||
Nauvis, an inhospitable world filled with dangerous creatures called biters. Build a factory,
|
||||
research new technologies, and become more efficient in your quest to build a rocket and return home.
|
||||
"""),
|
||||
"minecraft": ("Minecraft",
|
||||
"""
|
||||
Minecraft is a game about creativity. In a world made entirely of cubes, you explore, discover, mine,
|
||||
craft, and try not to explode. Delve deep into the earth and discover abandoned mines, ancient
|
||||
structures, and materials to create a portal to another world. Defeat the Ender Dragon, and claim
|
||||
victory!""")
|
||||
}
|
||||
# Start Playing Page
|
||||
@app.route('/start-playing')
|
||||
def start_playing():
|
||||
return render_template(f"startPlaying.html")
|
||||
|
||||
|
||||
# Game sub-pages
|
||||
@app.route('/games/<string:game>/<string:page>')
|
||||
def game_pages(game, page):
|
||||
return render_template(f"/games/{game}/{page}.html")
|
||||
@app.route('/weighted-settings')
|
||||
def weighted_settings():
|
||||
return render_template(f"weighted-settings.html")
|
||||
|
||||
|
||||
# Game landing pages
|
||||
@app.route('/games/<game>')
|
||||
def game_page(game):
|
||||
return render_template(f"/games/{game}/{game}.html")
|
||||
# Player settings pages
|
||||
@app.route('/games/<string:game>/player-settings')
|
||||
def player_settings(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, theme=get_world_theme(game))
|
||||
|
||||
|
||||
# List of supported games
|
||||
@app.route('/games')
|
||||
def games():
|
||||
return render_template("games/games.html", games_list=games_list)
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
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')
|
||||
@app.route('/tutorial/')
|
||||
def tutorial_landing():
|
||||
return render_template("tutorialLanding.html")
|
||||
|
||||
|
||||
@app.route('/weighted-settings')
|
||||
def weighted_settings():
|
||||
return render_template("weightedSettings.html")
|
||||
@app.route('/faq/<string:lang>/')
|
||||
def faq(lang):
|
||||
return render_template("faq.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/seed/<suuid:seed>')
|
||||
def viewSeed(seed: UUID):
|
||||
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>')
|
||||
@@ -151,7 +152,7 @@ def new_room(seed: UUID):
|
||||
abort(404)
|
||||
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
|
||||
commit()
|
||||
return redirect(url_for("hostRoom", room=room.id))
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
|
||||
def _read_log(path: str):
|
||||
@@ -165,20 +166,20 @@ def _read_log(path: str):
|
||||
|
||||
@app.route('/log/<suuid:room>')
|
||||
def display_log(room: UUID):
|
||||
# noinspection PyTypeChecker
|
||||
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||
|
||||
|
||||
@app.route('/hosted/<suuid:room>', methods=['GET', 'POST'])
|
||||
def hostRoom(room: UUID):
|
||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||
def host_room(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
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
|
||||
@@ -192,6 +193,21 @@ def favicon():
|
||||
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
||||
|
||||
|
||||
@app.route('/discord')
|
||||
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")
|
||||
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
|
||||
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -1,24 +1,49 @@
|
||||
"""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')
|
||||
@cache.cached()
|
||||
def get_datapackge():
|
||||
from worlds import network_data_package
|
||||
return network_data_package
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage_version')
|
||||
@cache.cached()
|
||||
def get_datapackge_versions():
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@ from pony.orm import commit
|
||||
|
||||
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||
from WebHostLib.check import get_yaml_data, roll_options
|
||||
from WebHostLib.generate import get_meta
|
||||
|
||||
|
||||
@api_endpoints.route('/generate', methods=['POST'])
|
||||
@@ -16,7 +17,7 @@ def generate_api():
|
||||
try:
|
||||
options = {}
|
||||
race = False
|
||||
|
||||
meta_options_source = {}
|
||||
if 'file' in request.files:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
@@ -24,14 +25,17 @@ def generate_api():
|
||||
return {"text": options}, 400
|
||||
if "race" in request.form:
|
||||
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
|
||||
meta_options_source = request.form
|
||||
|
||||
json_data = request.get_json()
|
||||
if json_data:
|
||||
meta_options_source = json_data
|
||||
if 'weights' in json_data:
|
||||
# example: options = {"player1weights" : {<weightsdata>}}
|
||||
options = json_data["weights"]
|
||||
if "race" in json_data:
|
||||
race = bool(0 if json_data["race"] in {"false"} else int(json_data["race"]))
|
||||
|
||||
if not options:
|
||||
return {"text": "No options found. Expected file attachment or json weights."
|
||||
}, 400
|
||||
@@ -39,7 +43,8 @@ def generate_api():
|
||||
if len(options) > app.config["MAX_ROLL"]:
|
||||
return {"text": "Max size of multiworld exceeded",
|
||||
"detail": app.config["MAX_ROLL"]}, 409
|
||||
|
||||
meta = get_meta(meta_options_source)
|
||||
meta["race"] = race
|
||||
results, gen_options = roll_options(options)
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return {"text": str(results),
|
||||
@@ -48,7 +53,7 @@ def generate_api():
|
||||
gen = Generation(
|
||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||
# convert to json compatible
|
||||
meta=json.dumps({"race": race}), state=STATE_QUEUED,
|
||||
meta=json.dumps(meta), state=STATE_QUEUED,
|
||||
owner=session["_id"])
|
||||
commit()
|
||||
return {"text": f"Generation of seed {gen.id} started successfully.",
|
||||
@@ -60,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)
|
||||
@@ -89,14 +89,14 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||
options = restricted_loads(generation.options)
|
||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||
pool.apply_async(gen_game, (options,),
|
||||
{"race": meta["race"],
|
||||
{"meta": meta,
|
||||
"sid": generation.id,
|
||||
"owner": generation.owner},
|
||||
handle_generation_success, handle_generation_failure)
|
||||
except:
|
||||
except Exception as e:
|
||||
generation.state = STATE_ERROR
|
||||
commit()
|
||||
raise
|
||||
logging.exception(e)
|
||||
else:
|
||||
generation.state = STATE_STARTED
|
||||
|
||||
@@ -110,6 +110,26 @@ def autohost(config: dict):
|
||||
def keep_running():
|
||||
try:
|
||||
with Locker("autohost"):
|
||||
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,22 +149,17 @@ 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)
|
||||
to_start = select(
|
||||
generation for generation in Generation if generation.state == STATE_QUEUED)
|
||||
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 = {}
|
||||
|
||||
@@ -12,7 +12,7 @@ def allowed_file(filename):
|
||||
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||
|
||||
|
||||
from Mystery import roll_settings
|
||||
from Generate import roll_settings
|
||||
from Utils import parse_yaml
|
||||
|
||||
|
||||
@@ -49,9 +49,7 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
for file in infolist:
|
||||
if file.filename.endswith(banned_zip_contents):
|
||||
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted."
|
||||
elif file.filename.endswith(".yaml"):
|
||||
options[file.filename] = zfile.open(file, "r").read()
|
||||
elif file.filename.endswith(".txt"):
|
||||
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
|
||||
options[file.filename] = zfile.open(file, "r").read()
|
||||
else:
|
||||
options = {file.filename: file.read()}
|
||||
@@ -73,7 +71,8 @@ def roll_options(options: Dict[str, Union[dict, str]]) -> Tuple[Dict[str, Union[
|
||||
results[filename] = f"Failed to parse YAML data in {filename}: {e}"
|
||||
else:
|
||||
try:
|
||||
rolled_results[filename] = roll_settings(yaml_data, plando_options={"bosses"})
|
||||
rolled_results[filename] = roll_settings(yaml_data,
|
||||
plando_options={"bosses", "items", "connections", "texts"})
|
||||
except Exception as e:
|
||||
results[filename] = f"Failed to generate mystery in {filename}: {e}"
|
||||
else:
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import websockets
|
||||
import asyncio
|
||||
import socket
|
||||
@@ -11,7 +10,7 @@ import time
|
||||
import random
|
||||
import pickle
|
||||
|
||||
|
||||
import Utils
|
||||
from .models import *
|
||||
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||
@@ -20,6 +19,7 @@ from Utils import get_public_ipv4, get_public_ipv6, restricted_loads
|
||||
|
||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
ctx: WebHostContext
|
||||
|
||||
def _cmd_video(self, platform, user):
|
||||
"""Set a link for your name in the WebHostLib tracker pointing to a video stream"""
|
||||
if platform.lower().startswith("t"): # twitch
|
||||
@@ -37,6 +37,7 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
|
||||
# inject
|
||||
import MultiServer
|
||||
|
||||
MultiServer.client_message_processor = CustomClientMessageProcessor
|
||||
del (MultiServer)
|
||||
|
||||
@@ -48,7 +49,7 @@ class DBCommandProcessor(ServerCommandProcessor):
|
||||
|
||||
class WebHostContext(Context):
|
||||
def __init__(self):
|
||||
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", 0, 2)
|
||||
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
|
||||
self.main_loop = asyncio.get_running_loop()
|
||||
self.video = {}
|
||||
self.tags = ["AP", "WebHost"]
|
||||
@@ -56,7 +57,7 @@ class WebHostContext(Context):
|
||||
def listen_to_db_commands(self):
|
||||
cmdprocessor = DBCommandProcessor(self)
|
||||
|
||||
while self.running:
|
||||
while not self.exit_event.is_set():
|
||||
with db_session:
|
||||
commands = select(command for command in Command if command.room.id == self.room_id)
|
||||
if commands:
|
||||
@@ -75,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):
|
||||
@@ -88,11 +89,11 @@ class WebHostContext(Context):
|
||||
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
||||
|
||||
@db_session
|
||||
def _save(self, exit_save:bool = False) -> bool:
|
||||
def _save(self, exit_save: bool = False) -> bool:
|
||||
room = Room.get(id=self.room_id)
|
||||
room.multisave = pickle.dumps(self.get_save())
|
||||
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||
room.last_activity = datetime.utcnow()
|
||||
return True
|
||||
|
||||
@@ -101,6 +102,7 @@ class WebHostContext(Context):
|
||||
d["video"] = [(tuple(playerslot), videodata) for playerslot, videodata in self.video.items()]
|
||||
return d
|
||||
|
||||
|
||||
def get_random_port():
|
||||
return random.randint(49152, 65535)
|
||||
|
||||
@@ -111,11 +113,7 @@ def run_server_process(room_id, ponyconfig: dict):
|
||||
db.generate_mapping(check_tables=False)
|
||||
|
||||
async def main():
|
||||
|
||||
logging.basicConfig(format='[%(asctime)s] %(message)s',
|
||||
level=logging.INFO,
|
||||
handlers=[
|
||||
logging.FileHandler(os.path.join(LOGS_FOLDER, f"{room_id}.txt"), 'a', 'utf-8-sig')])
|
||||
Utils.init_logging(str(room_id), write_mode="a")
|
||||
ctx = WebHostContext()
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from flask import send_file, Response
|
||||
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
|
||||
from WebHostLib import app, Slot, Room, Seed
|
||||
import zipfile
|
||||
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
|
||||
from WebHostLib import app, Slot, Room, Seed, cache
|
||||
|
||||
|
||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||
def download_patch(room_id, patch_id):
|
||||
@@ -11,15 +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}"
|
||||
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)}.apbp"
|
||||
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)
|
||||
|
||||
|
||||
@@ -28,27 +51,10 @@ def download_spoiler(seed_id):
|
||||
return Response(Seed.get(id=seed_id).spoiler, mimetype="text/plain")
|
||||
|
||||
|
||||
@app.route("/dl_raw_patch/<suuid:seed_id>/<int:player_id>")
|
||||
def download_raw_patch(seed_id, player_id: int):
|
||||
seed = Seed.get(id=seed_id)
|
||||
patch = select(patch for patch in seed.slots if
|
||||
patch.player_id == player_id).first()
|
||||
|
||||
if not patch:
|
||||
return "Patch not found"
|
||||
else:
|
||||
import io
|
||||
|
||||
patch_data = update_patch_data(patch.data, server="")
|
||||
patch_data = io.BytesIO(patch_data)
|
||||
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](seed_id)}.apbp"
|
||||
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
|
||||
|
||||
@app.route("/slot_file/<suuid:seed_id>/<int:player_id>")
|
||||
def download_slot_file(seed_id, player_id: int):
|
||||
seed = Seed.get(id=seed_id)
|
||||
slot_data: Slot = select(patch for patch in seed.slots if
|
||||
@app.route("/slot_file/<suuid:room_id>/<int:player_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()
|
||||
|
||||
if not slot_data:
|
||||
@@ -57,12 +63,31 @@ def download_slot_file(seed_id, player_id: int):
|
||||
import io
|
||||
|
||||
if slot_data.game == "Minecraft":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](seed_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
||||
from worlds.minecraft import mc_update_output
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
||||
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
|
||||
return send_file(io.BytesIO(data), as_attachment=True, attachment_filename=fname)
|
||||
elif slot_data.game == "Factorio":
|
||||
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"
|
||||
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"
|
||||
else:
|
||||
return "Game download not supported."
|
||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
|
||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
|
||||
|
||||
@app.route("/templates")
|
||||
@cache.cached()
|
||||
def list_yaml_templates():
|
||||
files = []
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
for world_name, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
files.append(world_name)
|
||||
return render_template("templates.html", files=files)
|
||||
@@ -2,19 +2,33 @@ import os
|
||||
import tempfile
|
||||
import random
|
||||
import json
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from typing import Dict, Optional as TypeOptional
|
||||
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 Main import get_seed, seeddigits
|
||||
from Mystery import handle_name
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
from Generate import handle_name
|
||||
import pickle
|
||||
|
||||
from .models import *
|
||||
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 = {
|
||||
"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"),
|
||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||
}
|
||||
return meta
|
||||
|
||||
|
||||
@app.route('/generate', methods=['GET', 'POST'])
|
||||
@@ -31,6 +45,14 @@ def generate(race=False):
|
||||
flash(options)
|
||||
else:
|
||||
results, gen_options = roll_options(options)
|
||||
# get form data -> server settings
|
||||
meta = get_meta(request.form)
|
||||
meta["race"] = race
|
||||
|
||||
if race:
|
||||
meta["item_cheat"] = False
|
||||
meta["remaining"] = False
|
||||
|
||||
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"]:
|
||||
@@ -40,7 +62,8 @@ def generate(race=False):
|
||||
gen = Generation(
|
||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||
# convert to json compatible
|
||||
meta=json.dumps({"race": race}), state=STATE_QUEUED,
|
||||
meta=json.dumps(meta),
|
||||
state=STATE_QUEUED,
|
||||
owner=session["_id"])
|
||||
commit()
|
||||
|
||||
@@ -48,18 +71,24 @@ def generate(race=False):
|
||||
else:
|
||||
try:
|
||||
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
||||
race=race, owner=session["_id"].int)
|
||||
meta=meta, owner=session["_id"].int)
|
||||
except BaseException as e:
|
||||
from .autolauncher import handle_generation_failure
|
||||
handle_generation_failure(e)
|
||||
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": "+ str(e)))
|
||||
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
|
||||
|
||||
return redirect(url_for("viewSeed", seed=seed_id))
|
||||
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, race=False, owner=None, sid=None):
|
||||
def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=None, sid=None):
|
||||
if not meta:
|
||||
meta: Dict[str, object] = {}
|
||||
|
||||
meta.setdefault("hint_cost", 10)
|
||||
race = meta.get("race", False)
|
||||
del (meta["race"])
|
||||
try:
|
||||
target = tempfile.TemporaryDirectory()
|
||||
playercount = len(gen_options)
|
||||
@@ -69,34 +98,34 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
|
||||
if race:
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
seedname = "M" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
||||
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.create_spoiler = not race
|
||||
erargs.spoiler = 0 if race else 2
|
||||
erargs.race = race
|
||||
erargs.skip_playthrough = race
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
erargs.create_diff = True
|
||||
|
||||
name_counter = Counter()
|
||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||
for k, v in settings.items():
|
||||
if v is not None:
|
||||
getattr(erargs, k)[player] = v
|
||||
if hasattr(erargs, k):
|
||||
getattr(erargs, k)[player] = v
|
||||
else:
|
||||
setattr(erargs, k, {player: v})
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
erargs.names = ",".join(erargs.name[i] for i in range(1, playercount + 1))
|
||||
del (erargs.name)
|
||||
ERmain(erargs, seed)
|
||||
|
||||
return upload_to_db(target.name, owner, sid, race)
|
||||
return upload_to_db(target.name, sid, owner, race)
|
||||
except BaseException as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
@@ -104,7 +133,7 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
|
||||
if gen is not None:
|
||||
gen.state = STATE_ERROR
|
||||
meta = json.loads(gen.meta)
|
||||
meta["error"] = (e.__class__.__name__ + ": "+ str(e))
|
||||
meta["error"] = (e.__class__.__name__ + ": " + str(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
|
||||
commit()
|
||||
@@ -116,7 +145,7 @@ def wait_seed(seed: UUID):
|
||||
seed_id = seed
|
||||
seed = Seed.get(id=seed_id)
|
||||
if seed:
|
||||
return redirect(url_for("viewSeed", seed=seed_id))
|
||||
return redirect(url_for("view_seed", seed=seed_id))
|
||||
generation = Generation.get(id=seed_id)
|
||||
|
||||
if not generation:
|
||||
@@ -126,37 +155,19 @@ def wait_seed(seed: UUID):
|
||||
return render_template("waitSeed.html", seed_id=seed_id)
|
||||
|
||||
|
||||
def upload_to_db(folder, owner, sid, race:bool):
|
||||
slots = set()
|
||||
spoiler = ""
|
||||
|
||||
multidata = None
|
||||
def upload_to_db(folder, sid, owner, race):
|
||||
for file in os.listdir(folder):
|
||||
file = os.path.join(folder, file)
|
||||
if file.endswith(".apbp"):
|
||||
player_text = file.split("_P", 1)[1]
|
||||
player_name = player_text.split("_", 1)[1].split(".", 1)[0]
|
||||
player_id = int(player_text.split(".", 1)[0].split("_", 1)[0])
|
||||
slots.add(Slot(data=open(file, "rb").read(),
|
||||
player_id=player_id, player_name = player_name, game = "A Link to the Past"))
|
||||
elif file.endswith(".txt"):
|
||||
spoiler = open(file, "rt", encoding="utf-8-sig").read()
|
||||
elif file.endswith(".archipelago"):
|
||||
multidata = open(file, "rb").read()
|
||||
if multidata:
|
||||
with db_session:
|
||||
if sid:
|
||||
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner,
|
||||
id=sid, meta=json.dumps({"race": race, "tags": ["generated"]}))
|
||||
else:
|
||||
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner,
|
||||
meta=json.dumps({"race": race, "tags": ["generated"]}))
|
||||
for patch in slots:
|
||||
patch.seed = seed
|
||||
if sid:
|
||||
gen = Generation.get(id=sid)
|
||||
if gen is not None:
|
||||
gen.delete()
|
||||
return seed.id
|
||||
else:
|
||||
raise Exception("Multidata required (.archipelago), but not found.")
|
||||
if file.endswith(".zip"):
|
||||
with db_session:
|
||||
with zipfile.ZipFile(file) as zfile:
|
||||
res = upload_zip_to_db(zfile, owner, {"race": race}, sid)
|
||||
if type(res) == "str":
|
||||
raise Exception(res)
|
||||
elif res:
|
||||
seed = res
|
||||
gen = Generation.get(id=seed.id)
|
||||
if gen is not None:
|
||||
gen.delete()
|
||||
return seed.id
|
||||
raise Exception("Generation zipfile not found.")
|
||||
|
||||
50
WebHostLib/lttpsprites.py
Normal file
50
WebHostLib/lttpsprites.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
import threading
|
||||
import json
|
||||
|
||||
from Utils import local_path, user_path
|
||||
from worlds.alttp.Rom import Sprite
|
||||
|
||||
|
||||
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 = 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()
|
||||
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():
|
||||
task.do_events()
|
||||
|
||||
spriteData = []
|
||||
|
||||
for file in os.listdir(input_dir):
|
||||
sprite = Sprite(os.path.join(input_dir, file))
|
||||
|
||||
if not sprite.name:
|
||||
print("Warning:", file, "has no name.")
|
||||
sprite.name = file.split(".", 1)[0]
|
||||
if sprite.valid:
|
||||
with open(os.path.join(output_dir, "sprites", f"{os.path.splitext(file)[0]}.gif"), 'wb') as image:
|
||||
image.write(get_image_for_sprite(sprite, True))
|
||||
spriteData.append({"file": file, "author": sprite.author_name, "name": sprite.name})
|
||||
else:
|
||||
print(file, "dropped, as it has no valid sprite data.")
|
||||
spriteData.sort(key=lambda entry: entry["name"])
|
||||
with open(f'{output_dir}/spriteData.json', 'w') as file:
|
||||
json.dump({"sprites": spriteData}, file, indent=1)
|
||||
return 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)
|
||||
@@ -40,7 +40,7 @@ class Seed(db.Entity):
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow())
|
||||
slots = Set(Slot)
|
||||
spoiler = Optional(LongStr, lazy=True)
|
||||
meta = Required(str, default=lambda: "{\"race\": false}") # additional meta information/tags
|
||||
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
|
||||
|
||||
|
||||
class Command(db.Entity):
|
||||
@@ -53,5 +53,5 @@ class Generation(db.Entity):
|
||||
id = PrimaryKey(UUID, default=uuid4)
|
||||
owner = Required(UUID)
|
||||
options = Required(buffer, lazy=True)
|
||||
meta = Required(str, default=lambda: "{\"race\": false}")
|
||||
meta = Required(LongStr, default=lambda: "{\"race\": false}")
|
||||
state = Required(int, default=0, index=True)
|
||||
|
||||
143
WebHostLib/options.py
Normal file
143
WebHostLib/options.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import logging
|
||||
import os
|
||||
from Utils import __version__
|
||||
from jinja2 import Template
|
||||
import yaml
|
||||
import json
|
||||
|
||||
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():
|
||||
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
|
||||
|
||||
def dictify_range(option):
|
||||
data = {option.range_start: 0, option.range_end: 0, "random": 0, "random-low": 0, "random-high": 0,
|
||||
option.default: 50}
|
||||
notes = {
|
||||
option.range_start: "minimum value",
|
||||
option.range_end: "maximum value"
|
||||
}
|
||||
return data, notes
|
||||
|
||||
def default_converter(default_value):
|
||||
if isinstance(default_value, (set, frozenset)):
|
||||
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 = {**world.options, **Options.per_game_common_options}
|
||||
res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
|
||||
options=all_options,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||
dictify_range=dictify_range, default_converter=default_converter,
|
||||
)
|
||||
|
||||
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
|
||||
f.write(res)
|
||||
|
||||
# Generate JSON files for player-settings pages
|
||||
player_settings = {
|
||||
"baseOptions": {
|
||||
"description": "Generated by https://archipelago.gg/",
|
||||
"game": game_name,
|
||||
"name": "Player",
|
||||
},
|
||||
}
|
||||
|
||||
game_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.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"defaultValue": None,
|
||||
"options": []
|
||||
}
|
||||
|
||||
for sub_option_id, sub_option_name in option.name_lookup.items():
|
||||
this_option["options"].append({
|
||||
"name": option.get_option_name(sub_option_id),
|
||||
"value": sub_option_name,
|
||||
})
|
||||
|
||||
if sub_option_id == option.default:
|
||||
this_option["defaultValue"] = sub_option_name
|
||||
|
||||
this_option["options"].append({
|
||||
"name": "Random",
|
||||
"value": "random",
|
||||
})
|
||||
|
||||
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
|
||||
game_options[option_name] = {
|
||||
"type": "range",
|
||||
"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,
|
||||
"min": option.range_start,
|
||||
"max": option.range_end,
|
||||
}
|
||||
|
||||
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:
|
||||
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,6 @@
|
||||
flask>=2.0.1
|
||||
pony>=0.7.14
|
||||
waitress>=2.0.0
|
||||
flask>=2.0.3
|
||||
pony>=0.7.16
|
||||
waitress>=2.1.0
|
||||
flask-caching>=1.10.1
|
||||
Flask-Compress>=1.10.1
|
||||
Flask-Limiter>=1.4
|
||||
Flask-Compress>=1.11
|
||||
Flask-Limiter>=2.2.0
|
||||
|
||||
53
WebHostLib/static/assets/faq.js
Normal file
53
WebHostLib/static/assets/faq.js
Normal file
@@ -0,0 +1,53 @@
|
||||
window.addEventListener('load', () => {
|
||||
const tutorialWrapper = document.getElementById('faq-wrapper');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, the tutorial is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the tutorial.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
|
||||
`faq_${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>`;
|
||||
});
|
||||
});
|
||||
63
WebHostLib/static/assets/faq/faq_en.md
Normal file
63
WebHostLib/static/assets/faq/faq_en.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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 at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
|
||||
any questions you might have.
|
||||
|
||||
## 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
|
||||
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.
|
||||
53
WebHostLib/static/assets/gameInfo.js
Normal file
53
WebHostLib/static/assets/gameInfo.js
Normal file
@@ -0,0 +1,53 @@
|
||||
window.addEventListener('load', () => {
|
||||
const gameInfo = document.getElementById('game-info');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, this game's info page is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the info page.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/gameInfo/` +
|
||||
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.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);
|
||||
gameInfo.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);
|
||||
gameInfo.innerHTML =
|
||||
`<h2>This page is out of logic!</h2>
|
||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
||||
});
|
||||
});
|
||||
32
WebHostLib/static/assets/gameInfo/en_A Link to the Past.md
Normal file
32
WebHostLib/static/assets/gameInfo/en_A Link to the Past.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# A Link to the Past
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) 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 main inventory items, collectables, 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 LttP?
|
||||
|
||||
Items belonging to other worlds are represented by a Power Star from Super Mario World.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
When the player receives an item, Link will hold the item above his head and display it to the world. It's good for
|
||||
business!
|
||||
|
||||
12
WebHostLib/static/assets/gameInfo/en_ArchipIDLE.md
Normal file
12
WebHostLib/static/assets/gameInfo/en_ArchipIDLE.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# ArchipIDLE
|
||||
|
||||
## What is this game?
|
||||
|
||||
ArchipIDLE is the 2022 Archipelago April Fools' Day joke. It is an idle game that sends a location check every
|
||||
thirty seconds, up to one hundred checks.
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure
|
||||
and export a config file.
|
||||
|
||||
24
WebHostLib/static/assets/gameInfo/en_ChecksFinder.md
Normal file
24
WebHostLib/static/assets/gameInfo/en_ChecksFinder.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# ChecksFinder
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What is considered a location check in ChecksFinder?
|
||||
|
||||
Location checks in are completed when the player finds a spot on a board that has the archipelago logo. The bottom of
|
||||
the screen has a number next to the archipelago logo, that number is how many you can find so far. You can only get as
|
||||
many checks as you have gained items, plus five to start with being available.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
When the player receives an item in ChecksFinder, it either can make the future boards they play be bigger in width or
|
||||
height, or add a new bomb to the future boards, with a limit to having up to one fifth of the _current_ board being
|
||||
bombs. The items you have gained _before_ the current board was made will be said at the bottom of the screen as a number
|
||||
next to an icon, the number is how many you have gotten and the icon represents which item it is.
|
||||
|
||||
## What is the victory condition?
|
||||
|
||||
Victory is achieved when the player wins a board they were given after they have received all of their Map Width, Map
|
||||
Height, and Map Bomb items. The game will say at the bottom of the screen how many of each you have received.
|
||||
34
WebHostLib/static/assets/gameInfo/en_Factorio.md
Normal file
34
WebHostLib/static/assets/gameInfo/en_Factorio.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Factorio
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
In Factorio, the research tree is shuffled, causing certain technologies to be obtained in a non-standard order. Recipe
|
||||
costs, technology requirements, and science pack requirements may also be shuffled at the player's discretion.
|
||||
|
||||
## What Factorio items can appear in other players' worlds?
|
||||
|
||||
Factorio's technologies are removed from its tech tree and placed into other players' worlds. When those technologies
|
||||
are found, they are sent back to Factorio along with, optionally, free samples of those technologies.
|
||||
|
||||
## What is a free sample?
|
||||
|
||||
A free sample is a single or stack of items in Factorio, granted by a technology received from another world. For
|
||||
example, receiving the technology `Portable Solar Panel` may also grant the player a stack of portable solar panels, and
|
||||
place them directly into the player's inventory.
|
||||
|
||||
## What does another world's item look like in Factorio?
|
||||
|
||||
In Factorio, items which need to be sent to other worlds appear in the tech tree as new research items. They are
|
||||
represented by the Archipelago icon, and must be researched as if it were a normal technology. Upon successful
|
||||
completion of research, the item will be sent to its home world.
|
||||
|
||||
## When the engineer receives an item, what happens?
|
||||
|
||||
When the player receives a technology, it is instantly learned and able to be crafted. A message will appear in the chat
|
||||
log to notify the player, and if free samples are enabled the player may also receive some items directly to their
|
||||
inventory.
|
||||
26
WebHostLib/static/assets/gameInfo/en_Final Fantasy.md
Normal file
26
WebHostLib/static/assets/gameInfo/en_Final Fantasy.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 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 on the FFR
|
||||
website: [FF1R Website](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 the main randomizer
|
||||
site: [FF1R Website](https://finalfantasyrandomizer.com/)
|
||||
|
||||
## What Final Fantasy items can appear in other players' worlds?
|
||||
|
||||
All items can appear in other players worlds. This includes consumables, shards, weapons, armor and, of course, key
|
||||
items.
|
||||
|
||||
## 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.
|
||||
22
WebHostLib/static/assets/gameInfo/en_Hollow Knight.md
Normal file
22
WebHostLib/static/assets/gameInfo/en_Hollow Knight.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Hollow Knight
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
Randomization swaps around the locations of items. The items being swapped around are chosen within your YAML.
|
||||
Shop costs are presently always randomized.
|
||||
|
||||
## What Hollow Knight items can appear in other players' worlds?
|
||||
|
||||
This is dependent entirely upon your YAML settings. Some examples include: charms, grubs, lifeblood cocoons, geo, etc.
|
||||
|
||||
## What does another world's item look like in Hollow Knight?
|
||||
|
||||
When the Hollow Knight player picks up an item from a location and it is an item for another game it will appear in that
|
||||
player's recent items display as an item being sent to another player. If the item is for another Hollow Knight player
|
||||
then the sprite will be that of the item's original sprite. If the item belongs to a player that is not playing Hollow
|
||||
Knight then the sprite will be the Archipelago logo.
|
||||
25
WebHostLib/static/assets/gameInfo/en_Meritous.md
Normal file
25
WebHostLib/static/assets/gameInfo/en_Meritous.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Meritous
|
||||
|
||||
## Where is the settings page?
|
||||
The [player settings page for Meritous](../player-settings) contains all the options you need to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
The PSI Enhancement Tiles have become general-purpose Item Caches, and all upgrades and artifacts are added to the multiworld item pool. Optionally, the progression-critical PSI Keys can also be added to the pool, as well as monster evolution traps which (in vanilla) trigger when bosses are defeated.
|
||||
|
||||
## What is the goal of Meritous when randomized?
|
||||
At minimum, you will need to get the PSI Keys, defeat the three bosses, retrieve the Cursed Seal, and return it to the entrance. Depending on your selected goal, you may also have to defeat the final boss, or you may also need to explore every last room of the Atlas Dome and retrieve the Agate Knife before getting the Cursed Seal and defeating the final boss' true form.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
Every item added to the multiworld pool (as outlined above) can be distributed to other players' worlds.
|
||||
|
||||
## What is considered a location check in Meritous?
|
||||
The Alpha, Beta, and Gamma item caches each have 24 checks to buy, increasing in cost each time. Reward chests obtained from clearing ambush rooms will contain up to 24 location checks, thereafter always awarding a cache of PSI Crystals. If enabled, PSI Key Pedestals will contain checks, which must be unlocked by eliminating a certain percentage of monsters. If enabled, defeating bosses will result in an automatic check.
|
||||
|
||||
## Which notable items are not randomized?
|
||||
The Cursed Seal and Agate Knife will always be in the farthest-away room from the Entrance and the final room explored, respectively.
|
||||
|
||||
## What does another world's item look like in Meritous?
|
||||
There is no visual representation of other players' items in Meritous. You will be buying checks from item caches and opening chests in ambush rooms blindly.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
A sound will play, and a notification will briefly appear on the lower half of the screen informing you of what you have received.
|
||||
27
WebHostLib/static/assets/gameInfo/en_Minecraft.md
Normal file
27
WebHostLib/static/assets/gameInfo/en_Minecraft.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Minecraft
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
Recipes are removed from the crafting book and shuffled into the item pool. It can also optionally change which
|
||||
structures appear in each dimension. Crafting recipes are re-learned when they are received from other players as item
|
||||
checks, and occasionally when completing your own achievements.
|
||||
|
||||
## What is considered a location check in minecraft?
|
||||
|
||||
Location checks in are completed when the player completes various Minecraft achievements. Opening the advancements menu
|
||||
in-game by pressing "L" will display outstanding achievements.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
When the player receives an item in Minecraft, it either unlocks crafting recipes or puts items into the player's
|
||||
inventory directly.
|
||||
|
||||
## What is the victory condition?
|
||||
|
||||
Victory is achieved when the player kills the Ender Dragon, enters the portal in The End, and completes the credits
|
||||
sequence either by skipping it or watching hit play out.
|
||||
32
WebHostLib/static/assets/gameInfo/en_Ocarina of Time.md
Normal file
32
WebHostLib/static/assets/gameInfo/en_Ocarina of Time.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Ocarina of Time
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) 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 main inventory items, collectables, and ammunition can be shuffled, and all locations in the game which could
|
||||
contain any of those items may have their contents changed. Gold Skultulla locations may also be included as necessary
|
||||
checks at the user's discretion.
|
||||
|
||||
## 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 OoT?
|
||||
|
||||
Items belonging to other worlds are represented by the Zelda's Letter item.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
When the player receives an item, Link will hold the item above his head and display it to the world. It's good for
|
||||
business!
|
||||
31
WebHostLib/static/assets/gameInfo/en_Raft.md
Normal file
31
WebHostLib/static/assets/gameInfo/en_Raft.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Raft
|
||||
|
||||
## 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?
|
||||
All of the items from the Research Table, as well as all the note/blueprint pickups from story islands, are changed to location checks. Blueprint items themselves are never given. The Research Table recipes will *remove* the researched items for that recipe once learned, meaning many more resources must be put into the Research Table to get all the unlocks from it.
|
||||
|
||||
## What is the goal of Raft when randomized?
|
||||
The goal remains the same: To pick up the note that has the frequency for the next unreleased story island from Tangaroa.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
All of the craftable items from the Research Table and Blueprints, as well as frequencies. Since there are more locations in Raft than there are items to receive, Resource Packs with basic earlygame materials and/or duplicate items may be added to the item pool (configurable).
|
||||
|
||||
## Which notable unlocks are not randomized?
|
||||
Most of the story island quests (actions that unlock new areas on the island) remain unchanged. There are three exceptions: The Balboa Island Relay Station quest, the Caravan Island zipline parts quest, and the Caravan Island battery charger quest have all been changed to an Archipelago unlock, as the rewards from these are craftable items or frequencies.
|
||||
Craftable items like the Machete are mixed into the Archipelago item pool, however quest items like Tape or Berries will function the same.
|
||||
Decoration Packages are unchanged.
|
||||
|
||||
## What does another world's item look like in Raft?
|
||||
Researches and pickups remain visually unchanged, regardless of what the unlock is.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
A Raft notification will appear with the item information. The unlock will also appear in the chat. Unlocks that would normally give you the item (eg Machete) will NOT give it to you, but must instead be crafted.
|
||||
|
||||
## Are there any limitations compared to vanilla Raft?
|
||||
- Mods that add new researchable technologies, modify story islands, or give items like blueprints are likely incompatible with Raftipelago.
|
||||
- Some mods that add items that are always craftable (eg don't add them to the Research Table) may be compatible.
|
||||
- Mods that do not affect items, notes, blueprints, or story islands have a good chance of being compatible with Raftipelago
|
||||
- No mods have been comprehensively tested or verified to work with Raftipelago. Use at your own risk.
|
||||
43
WebHostLib/static/assets/gameInfo/en_Risk of Rain 2.md
Normal file
43
WebHostLib/static/assets/gameInfo/en_Risk of Rain 2.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Risk of Rain 2
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) 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.
|
||||
27
WebHostLib/static/assets/gameInfo/en_Rogue Legacy.md
Normal file
27
WebHostLib/static/assets/gameInfo/en_Rogue Legacy.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Rogue Legacy (PC)
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) is located contains all the options you need to configure
|
||||
and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
You are not able to buy skill upgrades in the manor upgrade screen, and instead, need to find them in order to level up
|
||||
your character to make fighting the 5 bosses easier.
|
||||
|
||||
## What items and locations get shuffled?
|
||||
|
||||
All the skill upgrades, class upgrades, runes packs, and equipment packs are shuffled in the manor upgrade screen, diary
|
||||
checks, chests and fairy chests, and boss rewards. Skill upgrades are also grouped in packs of 5 to make the finding of
|
||||
stats less of a chore. Runes and Equipment are also grouped together.
|
||||
|
||||
## 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.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
When the player receives an item, your character will hold the item above their head and display it to the world. It's
|
||||
good for business!
|
||||
35
WebHostLib/static/assets/gameInfo/en_SMZ3.md
Normal file
35
WebHostLib/static/assets/gameInfo/en_SMZ3.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# SMZ3
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) 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 main inventory items, collectables, 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.
|
||||
|
||||
## What does another world's item look like in LttP?
|
||||
|
||||
Items belonging to other worlds are represented by a Power Star from Super Mario 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.
|
||||
|
||||
35
WebHostLib/static/assets/gameInfo/en_Secret of Evermore.md
Normal file
35
WebHostLib/static/assets/gameInfo/en_Secret of Evermore.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Secret of Evermore
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
Items which would normally be acquired throughout the game have been moved around! Progression logic remains, so the
|
||||
game is always able to be completed. However, because of the item shuffle, the player may need to access certain areas
|
||||
before they would in the vanilla game. For example, the Windwalker (flying machine) is accessible as soon as any weapon
|
||||
is obtained.
|
||||
|
||||
Additional help can be found in the [Evermizer guide](https://github.com/black-sliver/evermizer/blob/master/guide.md).
|
||||
|
||||
## What items and locations get shuffled?
|
||||
|
||||
All gourds/chests/pots, boss drops and alchemists are shuffled. Alchemy ingredients, sniff spot items, call bead spells
|
||||
and the dog can be randomized using yaml options.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
|
||||
Any of the items which can be shuffled may also be placed in another player's world. Specific items can be limited to
|
||||
your own world using plando.
|
||||
|
||||
## What does another world's item look like in Secret of Evermore?
|
||||
|
||||
Secret of Evermore will display "Sent an Item". Check the client output if you want to know which.
|
||||
|
||||
## What happens when the player receives an item?
|
||||
|
||||
When the player receives an item, a popup will appear to show which item was received. Items won't be received while a
|
||||
script is active such as when visiting Nobilia Market or during most Boss Fights. Once all scripts have ended, items
|
||||
will be received.
|
||||
35
WebHostLib/static/assets/gameInfo/en_Slay the Spire.md
Normal file
35
WebHostLib/static/assets/gameInfo/en_Slay the Spire.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Slay the Spire (PC)
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
Every non-boss relic drop, every boss relic and rare card drop, and every other card draw is replaced with an
|
||||
archipelago item. In heart runs, the blue key is also disconnected from the Archipelago item, so you can gather both.
|
||||
|
||||
## What items and locations get shuffled?
|
||||
|
||||
15 card packs, 10 relics, and 3 boss relics and rare card drops are shuffled into the item pool and can be found at any
|
||||
location that would normally give you these items, except for card packs, which are found at every other normal enemy
|
||||
encounter.
|
||||
|
||||
## 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.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
When the player receives an item, you will see the counter in the top right corner with the Archipelago symbol increment
|
||||
by one. By clicking on this icon, it'll open a menu that lists all the items you received, but have not yet accepted.
|
||||
You can take any relics and card packs sent to you and add them to your current run. It is advised that you do not open
|
||||
this menu until you are outside an encounter or event to prevent the game from soft-locking.
|
||||
|
||||
## What happens if a player dies in a run?
|
||||
|
||||
When a player dies, they will be taken back to the main menu and will need to reconnect to start climbing the spire from
|
||||
the beginning, but they will have access to all the items ever sent to them in the Archipelago menu in the top right.
|
||||
Any items found in an earlier run will not be sent again if you encounter them in the same location.
|
||||
34
WebHostLib/static/assets/gameInfo/en_Subnautica.md
Normal file
34
WebHostLib/static/assets/gameInfo/en_Subnautica.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Subnautica
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
The most noticeable change is the complete removal of freestanding technologies. The technology blueprints normally
|
||||
awarded from scanning those items have been shuffled into location checks throughout the AP item pool.
|
||||
|
||||
## What is the goal of Subnautica when randomized?
|
||||
|
||||
The goal remains unchanged. Cure the plague, build the Neptune Escape Rocket, and escape into space.
|
||||
|
||||
## What items and locations get shuffled?
|
||||
|
||||
Most of the technologies the player will need throughout the game will be shuffled. Location checks in Subnautica are
|
||||
data pads and technology lockers.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
|
||||
Most technologies may be shuffled into another player's world.
|
||||
|
||||
## What does another world's item look like in Subnautica?
|
||||
|
||||
Location checks in Subnautica are data pads and technology lockers. Opening one of these will send an item to another
|
||||
player's world.
|
||||
|
||||
## When the player receives a technology, what happens?
|
||||
|
||||
When the player receives a technology, the chat log displays a notification the technology has been received.
|
||||
|
||||
28
WebHostLib/static/assets/gameInfo/en_Super Mario 64.md
Normal file
28
WebHostLib/static/assets/gameInfo/en_Super Mario 64.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Super Mario 64 EX
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The player settings page for this game contains all the options you need to configure and export a config file. Player
|
||||
settings page link: [SM64EX Player Settings Page](../player-settings).
|
||||
|
||||
## What does randomization do to this game?
|
||||
All 120 Stars, the 3 Cap Switches, the Basement and Secound Floor Key are now Location Checks and may contain Items for different games as well
|
||||
as different Items from within SM64.
|
||||
|
||||
|
||||
## What is the goal of SM64EX when randomized?
|
||||
As in most Mario Games, save the Princess!
|
||||
|
||||
## Which items can be in another player's world?
|
||||
Any of the 120 Stars, and the two Caste Keys. Additionally, Cap Switches are also considered "Items" and the "!"-Boxes will only be active
|
||||
when someone collects the corresponding Cap Switch Item.
|
||||
|
||||
## What does another world's item look like in SM64EX?
|
||||
The Items are visually unchanged, though after collecting a Message will pop up to inform you what you collected,
|
||||
and who will receive it.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
When you receive an Item, a Message will pop up to inform you where you received the Item from,
|
||||
and which one it is.
|
||||
|
||||
NOTE: The Secret Star count in the Menu is broken.
|
||||
31
WebHostLib/static/assets/gameInfo/en_Super Metroid.md
Normal file
31
WebHostLib/static/assets/gameInfo/en_Super Metroid.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Super Metroid
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) 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.
|
||||
|
||||
38
WebHostLib/static/assets/gameInfo/en_Timespinner.md
Normal file
38
WebHostLib/static/assets/gameInfo/en_Timespinner.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Timespinner
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) 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, therefore 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 familiars 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 ([Elemental Beads Wiki Page](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.
|
||||
|
||||
36
WebHostLib/static/assets/gameInfo/en_VVVVVV.md
Normal file
36
WebHostLib/static/assets/gameInfo/en_VVVVVV.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# VVVVVV
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The player settings page for this game contains all the options you need to configure and export a config file. Player
|
||||
settings page link: [VVVVVV Player Settings Page](../player-settings).
|
||||
|
||||
## What does randomization do to this game?
|
||||
All 20 Trinkets are now Location Checks and may not actually contain Trinkets, but Items for different games.
|
||||
|
||||
Optionally, you may enable DoorCost, which will gate away some areas:
|
||||
- Laboratory
|
||||
- The Tower
|
||||
- Space Station 2 and
|
||||
- Warp Zone
|
||||
until you've collected some Trinkets.
|
||||
Examples:
|
||||
- If you set DoorCost at 2, then to enter Laboratory you will need Trinkets 1-2, for The Tower 3-4, etc.
|
||||
- If you set DoorCost at 3, then to enter Laboratory you will need Trinkets 1-3, for The Tower 4-6, etc.
|
||||
|
||||
## What is the goal of VVVVVV when randomized?
|
||||
Save all crew members, and finish the story.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
Any of the 20 Trinkets.
|
||||
|
||||
## What does another world's item look like in VVVVVV?
|
||||
The Trinkets are visually unchanged, though after collecting a textbox will pop up to inform you what you collected,
|
||||
and who will receive it.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
When you receive a Trinket, the standard Animation will play. Afterwards a textbox will inform you where
|
||||
you received the Trinket from, and which one it is.
|
||||
|
||||
NOTE: You can't check your trinkets in the Spaceship. Instead, you can check them in the pause menu under 'Stats'.
|
||||
This is especially useful if you have DoorCost enabled.
|
||||
2
WebHostLib/static/assets/md5.min.js
vendored
Normal file
2
WebHostLib/static/assets/md5.min.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// Copyright © 2011 Sebastian Tschan, https://blueimp.net
|
||||
!function(n){"use strict";function d(n,t){var r=(65535&n)+(65535&t);return(n>>16)+(t>>16)+(r>>16)<<16|65535&r}function f(n,t,r,e,o,u){return d((c=d(d(t,n),d(e,u)))<<(f=o)|c>>>32-f,r);var c,f}function l(n,t,r,e,o,u,c){return f(t&r|~t&e,n,t,o,u,c)}function v(n,t,r,e,o,u,c){return f(t&e|r&~e,n,t,o,u,c)}function g(n,t,r,e,o,u,c){return f(t^r^e,n,t,o,u,c)}function m(n,t,r,e,o,u,c){return f(r^(t|~e),n,t,o,u,c)}function i(n,t){var r,e,o,u;n[t>>5]|=128<<t%32,n[14+(t+64>>>9<<4)]=t;for(var c=1732584193,f=-271733879,i=-1732584194,a=271733878,h=0;h<n.length;h+=16)c=l(r=c,e=f,o=i,u=a,n[h],7,-680876936),a=l(a,c,f,i,n[h+1],12,-389564586),i=l(i,a,c,f,n[h+2],17,606105819),f=l(f,i,a,c,n[h+3],22,-1044525330),c=l(c,f,i,a,n[h+4],7,-176418897),a=l(a,c,f,i,n[h+5],12,1200080426),i=l(i,a,c,f,n[h+6],17,-1473231341),f=l(f,i,a,c,n[h+7],22,-45705983),c=l(c,f,i,a,n[h+8],7,1770035416),a=l(a,c,f,i,n[h+9],12,-1958414417),i=l(i,a,c,f,n[h+10],17,-42063),f=l(f,i,a,c,n[h+11],22,-1990404162),c=l(c,f,i,a,n[h+12],7,1804603682),a=l(a,c,f,i,n[h+13],12,-40341101),i=l(i,a,c,f,n[h+14],17,-1502002290),c=v(c,f=l(f,i,a,c,n[h+15],22,1236535329),i,a,n[h+1],5,-165796510),a=v(a,c,f,i,n[h+6],9,-1069501632),i=v(i,a,c,f,n[h+11],14,643717713),f=v(f,i,a,c,n[h],20,-373897302),c=v(c,f,i,a,n[h+5],5,-701558691),a=v(a,c,f,i,n[h+10],9,38016083),i=v(i,a,c,f,n[h+15],14,-660478335),f=v(f,i,a,c,n[h+4],20,-405537848),c=v(c,f,i,a,n[h+9],5,568446438),a=v(a,c,f,i,n[h+14],9,-1019803690),i=v(i,a,c,f,n[h+3],14,-187363961),f=v(f,i,a,c,n[h+8],20,1163531501),c=v(c,f,i,a,n[h+13],5,-1444681467),a=v(a,c,f,i,n[h+2],9,-51403784),i=v(i,a,c,f,n[h+7],14,1735328473),c=g(c,f=v(f,i,a,c,n[h+12],20,-1926607734),i,a,n[h+5],4,-378558),a=g(a,c,f,i,n[h+8],11,-2022574463),i=g(i,a,c,f,n[h+11],16,1839030562),f=g(f,i,a,c,n[h+14],23,-35309556),c=g(c,f,i,a,n[h+1],4,-1530992060),a=g(a,c,f,i,n[h+4],11,1272893353),i=g(i,a,c,f,n[h+7],16,-155497632),f=g(f,i,a,c,n[h+10],23,-1094730640),c=g(c,f,i,a,n[h+13],4,681279174),a=g(a,c,f,i,n[h],11,-358537222),i=g(i,a,c,f,n[h+3],16,-722521979),f=g(f,i,a,c,n[h+6],23,76029189),c=g(c,f,i,a,n[h+9],4,-640364487),a=g(a,c,f,i,n[h+12],11,-421815835),i=g(i,a,c,f,n[h+15],16,530742520),c=m(c,f=g(f,i,a,c,n[h+2],23,-995338651),i,a,n[h],6,-198630844),a=m(a,c,f,i,n[h+7],10,1126891415),i=m(i,a,c,f,n[h+14],15,-1416354905),f=m(f,i,a,c,n[h+5],21,-57434055),c=m(c,f,i,a,n[h+12],6,1700485571),a=m(a,c,f,i,n[h+3],10,-1894986606),i=m(i,a,c,f,n[h+10],15,-1051523),f=m(f,i,a,c,n[h+1],21,-2054922799),c=m(c,f,i,a,n[h+8],6,1873313359),a=m(a,c,f,i,n[h+15],10,-30611744),i=m(i,a,c,f,n[h+6],15,-1560198380),f=m(f,i,a,c,n[h+13],21,1309151649),c=m(c,f,i,a,n[h+4],6,-145523070),a=m(a,c,f,i,n[h+11],10,-1120210379),i=m(i,a,c,f,n[h+2],15,718787259),f=m(f,i,a,c,n[h+9],21,-343485551),c=d(c,r),f=d(f,e),i=d(i,o),a=d(a,u);return[c,f,i,a]}function a(n){for(var t="",r=32*n.length,e=0;e<r;e+=8)t+=String.fromCharCode(n[e>>5]>>>e%32&255);return t}function h(n){var t=[];for(t[(n.length>>2)-1]=void 0,e=0;e<t.length;e+=1)t[e]=0;for(var r=8*n.length,e=0;e<r;e+=8)t[e>>5]|=(255&n.charCodeAt(e/8))<<e%32;return t}function e(n){for(var t,r="0123456789abcdef",e="",o=0;o<n.length;o+=1)t=n.charCodeAt(o),e+=r.charAt(t>>>4&15)+r.charAt(15&t);return e}function r(n){return unescape(encodeURIComponent(n))}function o(n){return a(i(h(t=r(n)),8*t.length));var t}function u(n,t){return function(n,t){var r,e,o=h(n),u=[],c=[];for(u[15]=c[15]=void 0,16<o.length&&(o=i(o,8*n.length)),r=0;r<16;r+=1)u[r]=909522486^o[r],c[r]=1549556828^o[r];return e=i(u.concat(h(t)),512+8*t.length),a(i(c.concat(e),640))}(r(n),r(t))}function t(n,t,r){return t?r?u(t,n):e(u(t,n)):r?o(n):e(o(n))}"function"==typeof define&&define.amd?define(function(){return t}):"object"==typeof module&&module.exports?module.exports=t:n.md5=t}(this);
|
||||
49
WebHostLib/static/assets/minecraftTracker.js
Normal file
49
WebHostLib/static/assets/minecraftTracker.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;
|
||||
});
|
||||
}
|
||||
});
|
||||
52
WebHostLib/static/assets/ootTracker.js
Normal file
52
WebHostLib/static/assets/ootTracker.js
Normal file
@@ -0,0 +1,52 @@
|
||||
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, small keys, and boss keys in the location-table
|
||||
const types = ['counter', 'smallkeys', 'bosskeys'];
|
||||
for (let j = 0; j < types.length; j++) {
|
||||
let counters = document.getElementsByClassName(types[j]);
|
||||
const fakeCounters = fakeDOM.getElementsByClassName(types[j]);
|
||||
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
248
WebHostLib/static/assets/player-settings.js
Normal file
248
WebHostLib/static/assets/player-settings.js
Normal file
@@ -0,0 +1,248 @@
|
||||
let gameName = null;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
gameName = document.getElementById('player-settings').getAttribute('data-game');
|
||||
|
||||
// Update game name on page
|
||||
document.getElementById('game-name').innerText = gameName;
|
||||
|
||||
Promise.all([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]));
|
||||
localStorage.removeItem(gameName);
|
||||
settingHash = md5(results[0]);
|
||||
}
|
||||
|
||||
if (settingHash !== md5(results[0])) {
|
||||
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]);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('export-settings').addEventListener('click', () => exportSettings());
|
||||
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
|
||||
document.getElementById('generate-game').addEventListener('click', () => generateGame());
|
||||
|
||||
// Name input field
|
||||
const playerSettings = JSON.parse(localStorage.getItem(gameName));
|
||||
const nameInput = document.getElementById('player-name');
|
||||
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
|
||||
nameInput.value = playerSettings.name;
|
||||
}).catch((error) => {
|
||||
const url = new URL(window.location.href);
|
||||
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
|
||||
})
|
||||
});
|
||||
|
||||
const resetSettings = () => {
|
||||
localStorage.removeItem(gameName);
|
||||
localStorage.removeItem(`${gameName}-hash`)
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const fetchSettingData = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status !== 200) {
|
||||
reject(ajax.responseText);
|
||||
return;
|
||||
}
|
||||
try{ resolve(JSON.parse(ajax.responseText)); }
|
||||
catch(error){ reject(error); }
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/player-settings/${gameName}.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
const createDefaultSettings = (settingData) => {
|
||||
if (!localStorage.getItem(gameName)) {
|
||||
const newSettings = {
|
||||
[gameName]: {},
|
||||
};
|
||||
for (let baseOption of Object.keys(settingData.baseOptions)){
|
||||
newSettings[baseOption] = settingData.baseOptions[baseOption];
|
||||
}
|
||||
for (let gameOption of Object.keys(settingData.gameOptions)){
|
||||
newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue;
|
||||
}
|
||||
localStorage.setItem(gameName, JSON.stringify(newSettings));
|
||||
}
|
||||
};
|
||||
|
||||
const buildUI = (settingData) => {
|
||||
// Game Options
|
||||
const leftGameOpts = {};
|
||||
const rightGameOpts = {};
|
||||
Object.keys(settingData.gameOptions).forEach((key, index) => {
|
||||
if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; }
|
||||
else { rightGameOpts[key] = settingData.gameOptions[key]; }
|
||||
});
|
||||
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
|
||||
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
|
||||
};
|
||||
|
||||
const buildOptionsTable = (settings, romOpts = false) => {
|
||||
const currentSettings = JSON.parse(localStorage.getItem(gameName));
|
||||
const table = document.createElement('table');
|
||||
const tbody = document.createElement('tbody');
|
||||
|
||||
Object.keys(settings).forEach((setting) => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
// td Left
|
||||
const tdl = document.createElement('td');
|
||||
const label = document.createElement('label');
|
||||
label.setAttribute('for', setting);
|
||||
label.setAttribute('data-tooltip', settings[setting].description);
|
||||
label.innerText = `${settings[setting].displayName}:`;
|
||||
tdl.appendChild(label);
|
||||
tr.appendChild(tdl);
|
||||
|
||||
// td Right
|
||||
const tdr = document.createElement('td');
|
||||
let element = null;
|
||||
|
||||
switch(settings[setting].type){
|
||||
case 'select':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('select-container');
|
||||
let select = document.createElement('select');
|
||||
select.setAttribute('id', setting);
|
||||
select.setAttribute('data-key', setting);
|
||||
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
|
||||
settings[setting].options.forEach((opt) => {
|
||||
const option = document.createElement('option');
|
||||
option.setAttribute('value', opt.value);
|
||||
option.innerText = opt.name;
|
||||
if ((isNaN(currentSettings[gameName][setting]) &&
|
||||
(parseInt(opt.value, 10) === parseInt(currentSettings[gameName][setting]))) ||
|
||||
(opt.value === currentSettings[gameName][setting]))
|
||||
{
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.addEventListener('change', (event) => updateGameSetting(event));
|
||||
element.appendChild(select);
|
||||
break;
|
||||
|
||||
case 'range':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('range-container');
|
||||
|
||||
let range = document.createElement('input');
|
||||
range.setAttribute('type', 'range');
|
||||
range.setAttribute('data-key', setting);
|
||||
range.setAttribute('min', settings[setting].min);
|
||||
range.setAttribute('max', settings[setting].max);
|
||||
range.value = currentSettings[gameName][setting];
|
||||
range.addEventListener('change', (event) => {
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event);
|
||||
});
|
||||
element.appendChild(range);
|
||||
|
||||
let rangeVal = document.createElement('span');
|
||||
rangeVal.classList.add('range-value');
|
||||
rangeVal.setAttribute('id', `${setting}-value`);
|
||||
rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
||||
element.appendChild(rangeVal);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown setting type: ${settings[setting].type}`);
|
||||
console.error(setting);
|
||||
return;
|
||||
}
|
||||
|
||||
tdr.appendChild(element);
|
||||
tr.appendChild(tdr);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
};
|
||||
|
||||
const updateBaseSetting = (event) => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value);
|
||||
localStorage.setItem(gameName, JSON.stringify(options));
|
||||
};
|
||||
|
||||
const updateGameSetting = (event) => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
localStorage.setItem(gameName, JSON.stringify(options));
|
||||
};
|
||||
|
||||
const exportSettings = () => {
|
||||
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!');
|
||||
}
|
||||
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
||||
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
|
||||
};
|
||||
|
||||
/** Create an anchor and trigger a download of a text file. */
|
||||
const download = (filename, text) => {
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
|
||||
downloadLink.setAttribute('download', filename);
|
||||
downloadLink.style.display = 'none';
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
};
|
||||
|
||||
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: settings },
|
||||
presetData: { player: settings },
|
||||
playerCount: 1,
|
||||
race: raceMode ? '1' : '0',
|
||||
}).then((response) => {
|
||||
window.location.href = response.data.url;
|
||||
}).catch((error) => {
|
||||
let userMessage = 'Something went wrong and your game could not be generated.';
|
||||
if (error.response.data.text) {
|
||||
userMessage += ' ' + error.response.data.text;
|
||||
}
|
||||
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
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
49
WebHostLib/static/assets/timespinnerTracker.js
Normal file
49
WebHostLib/static/assets/timespinnerTracker.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;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,10 @@ window.addEventListener('load', () => {
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
// Populate page with HTML generated from markdown
|
||||
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,25 +1,28 @@
|
||||
# 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.
|
||||
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).
|
||||
|
||||
MSU packs are constantly in development. We won't link to any packs as most include ripped music from other media.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
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
|
||||
@@ -30,10 +33,12 @@ 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.
|
||||
|
||||
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
|
||||
@@ -45,13 +50,16 @@ 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.
|
||||
@@ -63,14 +71,8 @@ alttp_msu-34.pcm
|
||||
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)
|
||||
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.
|
||||
@@ -1,24 +1,31 @@
|
||||
# MSU-1 Guía de instalación
|
||||
|
||||
## Que es MSU-1?
|
||||
MSU-1 permite el uso de música personalizada durante el juego. Funciona en hardware original, la SuperNT, y algunos emuladores.
|
||||
Esta guiá explicará como encontrar los packs de música personalizada, comúnmente llamados pack MSU, y como configurarlos
|
||||
para su uso en hardware original, la SuperNT, and el emulador snes9x.
|
||||
|
||||
MSU-1 permite el uso de música personalizada durante el juego. Funciona en hardware original, la SuperNT, y algunos
|
||||
emuladores. Esta guiá explicará como encontrar los packs de música personalizada, comúnmente llamados pack MSU, y como
|
||||
configurarlos para su uso en hardware original, la SuperNT, and el emulador snes9x.
|
||||
|
||||
## Donde encontrar packs MSU
|
||||
Los packs MSU están constantemente en desarrollo. Puedes encontrar una lista de pack completos, al igual que packs en desarrollo en
|
||||
|
||||
Los packs MSU están constantemente en desarrollo. Puedes encontrar una lista de pack completos, al igual que packs en
|
||||
desarrollo en
|
||||
[esta hoja de calculo Google](https://docs.google.com/spreadsheets/d/1XRkR4Xy6S24UzYkYBAOv-VYWPKZIoUKgX04RbjF128Q).
|
||||
|
||||
## Que pinta debe tener un pack MSU
|
||||
Los packs MSU contienen muchos ficheros, la mayoria de los cuales son los archivos de música que se usaran durante el juego. Estos ficheros
|
||||
deben tener un nombre similar, con un guión seguido por un número al final, y tienen extensión`.pcm`. No importa como se llame
|
||||
cada archivo de música, siempre y cuando todos sigan el mismo patrón. El nombre más popular es
|
||||
|
||||
Los packs MSU contienen muchos ficheros, la mayoria de los cuales son los archivos de música que se usaran durante el
|
||||
juego. Estos ficheros deben tener un nombre similar, con un guión seguido por un número al final, y tienen
|
||||
extensión`.pcm`. No importa como se llame cada archivo de música, siempre y cuando todos sigan el mismo patrón. El
|
||||
nombre más popular es
|
||||
`alttp_msu-X.pcm`, donde X es un número.
|
||||
|
||||
Hay otro tipo de fichero que deberias encontrar en el directorio de un pack MSU. Este archivo indica al hardware o
|
||||
emulador que MSU debe ser activado para este juego. El fichero tiene un nombre similar al resto, pero tiene como extensión `.msu` y su tamaño es 0 KB.
|
||||
Hay otro tipo de fichero que deberias encontrar en el directorio de un pack MSU. Este archivo indica al hardware o
|
||||
emulador que MSU debe ser activado para este juego. El fichero tiene un nombre similar al resto, pero tiene como
|
||||
extensión `.msu` y su tamaño es 0 KB.
|
||||
|
||||
Un pequeño ejemplo de los contenidos de un directorio que contiene un pack MSU:
|
||||
|
||||
```
|
||||
Lista de ficheros dentro de un directorio de pack MSU:
|
||||
alttp_msu.msu
|
||||
@@ -29,10 +36,12 @@ alttp_msu-34.pcm
|
||||
```
|
||||
|
||||
## Como usar un pack MSU
|
||||
En todos los casos, debes renombrar tu fichero de ROM para que coincida con el resto de nombres de fichero del directorio, y copiar/pegar tu fichero rom
|
||||
dentro de dicho directorio.
|
||||
|
||||
En todos los casos, debes renombrar tu fichero de ROM para que coincida con el resto de nombres de fichero del
|
||||
directorio, y copiar/pegar tu fichero rom dentro de dicho directorio.
|
||||
|
||||
Esto hara que los contenidos del directorio sean los siguientes:
|
||||
|
||||
```
|
||||
Lista de ficheros dentro del directorio de pack MSU:
|
||||
alttp_msu.msu
|
||||
@@ -44,13 +53,16 @@ alttp_msu-34.pcm
|
||||
```
|
||||
|
||||
### Con snes9x
|
||||
|
||||
1. Carga el fichero de rom en snes9x.
|
||||
|
||||
### Con SD2SNES / FXPak en hardware original
|
||||
|
||||
1. Carga tu directorio de pack MSU en tu SD2SNES / FXPak.
|
||||
2. Navega hasta el directorio de pack MSU y carga la ROM
|
||||
|
||||
### Con SD2SNES / FXPak en SuperNT
|
||||
|
||||
1. Carga tu directorio de pack MSU en tu SD2SNES / FXPak.
|
||||
2. Enciende tu SuperNT y navega al menú `Settings`.
|
||||
3. Entra en la opcion `Audio`.
|
||||
@@ -62,13 +74,17 @@ alttp_msu-34.pcm
|
||||
9. Navega hasta el directorio de pack MSU y carga la ROM
|
||||
|
||||
## Aviso a streamers
|
||||
Muchos packs MSU usan música con derechos de autor la cual no esta permitido su uso en plataformas como Twitch o YouTube.
|
||||
Si elijes hacer stream de dicha música, tu VOD puede ser silenciado. En el peor caso, puedes recibir una orden de eliminación DMCA.
|
||||
Por favor, tened cuidado y solo streamear música para la cual tengas los derechos para hacerlo.
|
||||
|
||||
Muchos packs MSU usan música con derechos de autor la cual no esta permitido su uso en plataformas como Twitch o
|
||||
YouTube. Si elijes hacer stream de dicha música, tu VOD puede ser silenciado. En el peor caso, puedes recibir una orden
|
||||
de eliminación DMCA. Por favor, tened cuidado y solo streamear música para la cual tengas los derechos para hacerlo.
|
||||
|
||||
##### Packs MSU seguros para Stream
|
||||
A continuación enumeramos los packs MSU que, packs which, por lo que sabemos, son seguros para vuestras retransmisiones. Se iran añadiendo mas conforme
|
||||
vayamos enterandonos. Si sabes alguno que podamos haber olvidado, por favor haznoslo saber!
|
||||
|
||||
A continuación enumeramos los packs MSU que, packs which, por lo que sabemos, son seguros para vuestras retransmisiones.
|
||||
Se iran añadiendo mas conforme vayamos enterandonos. Si sabes alguno que podamos haber olvidado, por favor haznoslo
|
||||
saber!
|
||||
|
||||
- Musica del juego original
|
||||
- [Smooth McGroove](https://drive.google.com/open?id=1JDa1jCKg5hG0Km6xNpmIgf4kDMOxVp3n)
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
# Guide d'installation de MSU-1
|
||||
|
||||
## Qu'est-ce que MSU-1 ?
|
||||
MSU-1 permet l'utilisation de musiques en jeu personnalisées. Cela fonctionne sur une console originale, sur SuperNT, et sur certains émulateurs.
|
||||
Ce guide explique comment trouver des packs de musiques personnalisées, couremment appelées packs MSU, et comment les configurer
|
||||
pour les utiliser sur console, sur SuperNT et sur l'émulateur snes9x.
|
||||
|
||||
MSU-1 permet l'utilisation de musiques en jeu personnalisées. Cela fonctionne sur une console originale, sur SuperNT, et
|
||||
sur certains émulateurs. Ce guide explique comment trouver des packs de musiques personnalisées, couremment appelées
|
||||
packs MSU, et comment les configurer pour les utiliser sur console, sur SuperNT et sur l'émulateur snes9x.
|
||||
|
||||
## Où trouver des packs MSU
|
||||
Les packs MSU sont constamment en développement. Vous pouvez trouver une liste de packs complétés, ainsi que des packs en développement sur
|
||||
|
||||
Les packs MSU sont constamment en développement. Vous pouvez trouver une liste de packs complétés, ainsi que des packs
|
||||
en développement sur
|
||||
[cette feuille de calcul Google](https://docs.google.com/spreadsheets/d/1XRkR4Xy6S24UzYkYBAOv-VYWPKZIoUKgX04RbjF128Q).
|
||||
|
||||
## A quoi ressemble un pack MSU
|
||||
Les packs MSU contiennent beaucoup de fichiers, la plupart étant des fichiers musicaux qui seront utilisés en cours de jeu. Ces fichiers
|
||||
doivent être nommés de façon similaire, avec un nombre derrière le tiret, puis l'extension `.pcm`. Le nom de chaque fichier
|
||||
n'importe pas, du moment qu'ils suivent tous le même motif. Le nom le plus populaire que vous verrez est
|
||||
|
||||
Les packs MSU contiennent beaucoup de fichiers, la plupart étant des fichiers musicaux qui seront utilisés en cours de
|
||||
jeu. Ces fichiers doivent être nommés de façon similaire, avec un nombre derrière le tiret, puis l'extension `.pcm`. Le
|
||||
nom de chaque fichier n'importe pas, du moment qu'ils suivent tous le même motif. Le nom le plus populaire que vous
|
||||
verrez est
|
||||
`alttp_msu-X.pcm`, où X est remplacé par un nombre.
|
||||
|
||||
Il existe un autre type de fichier que vous devriez trouver dans le dossier d'un pack MSU. Ce fichier indique au matériel
|
||||
ou à l'émulateur que MSU doit être activé pour ce jeu. Ce fichier doit être nommé de façon similaires aux autres dans
|
||||
le dossier, mais il aura une extension `.msu` et pèsera 0 KB.
|
||||
Il existe un autre type de fichier que vous devriez trouver dans le dossier d'un pack MSU. Ce fichier indique au
|
||||
matériel ou à l'émulateur que MSU doit être activé pour ce jeu. Ce fichier doit être nommé de façon similaires aux
|
||||
autres dans le dossier, mais il aura une extension `.msu` et pèsera 0 KB.
|
||||
|
||||
Voici un exemple de ce à quoi ressemble le dossier d'un pack MSU :
|
||||
|
||||
```
|
||||
Liste des fichiers dans le dossier d'un pack MSU :
|
||||
alttp_msu.msu
|
||||
@@ -30,10 +36,12 @@ alttp_msu-34.pcm
|
||||
```
|
||||
|
||||
## Comment utiliser un pack MSU
|
||||
Dans tous les cas, vosu devez renommer votre fichier ROM pour qu'il corresponde au même motif que les autres fichiers dans le dossier du pack MSU,
|
||||
ensuite vous placez votre fichier ROM dans ce dossier.
|
||||
|
||||
Dans tous les cas, vosu devez renommer votre fichier ROM pour qu'il corresponde au même motif que les autres fichiers
|
||||
dans le dossier du pack MSU, ensuite vous placez votre fichier ROM dans ce dossier.
|
||||
|
||||
Le contenu du dossier ressemblera alors à ceci :
|
||||
|
||||
```
|
||||
Liste des fichiers dans le dossier d'un pack MSU :
|
||||
alttp_msu.msu
|
||||
@@ -45,13 +53,16 @@ alttp_msu-34.pcm
|
||||
```
|
||||
|
||||
### Avec snes9x
|
||||
|
||||
1. Chargez le fichier ROM depuis snes9x.
|
||||
|
||||
### Avec un SD2SNES / FXPak sur une console originale
|
||||
|
||||
1. Mettez le dossier du pack MSU avec la ROM sur votre SD2SNES / FXPak.
|
||||
2. Naviguez vers ce dossier et chargez votre ROM.
|
||||
|
||||
### Avec un SD2SNES / FXPak sur SuperNT
|
||||
|
||||
1. Mettez le dossier du pack MSU avec la ROM sur votre SD2SNES / FXPak.
|
||||
2. Allumez votre SuperNT et naviguez vers le menu `Settings` (paramètres).
|
||||
3. Entrez dans les paramètres `Audio`.
|
||||
@@ -63,6 +74,8 @@ alttp_msu-34.pcm
|
||||
9. Naviguez vers le dossier du pack MSU et chargez votre ROM.
|
||||
|
||||
## Avertissement pour les streamers
|
||||
Beaucoup de packs MSU utilisent des musiques copyrightées ce qui n'est pas permis sur des plateformes comme Twitch et YouTube.
|
||||
Si vous choisissez de streamer des musiques copyrightées, votre VOD sera peut-être rendue muette. Dans le pire des cas, vous pourriez recevoir
|
||||
une plainte DMCA pour faire retirer la vidéo. Faites attention à streamer uniquement des musiques pour lesquelles vous avez le droit.
|
||||
|
||||
Beaucoup de packs MSU utilisent des musiques copyrightées ce qui n'est pas permis sur des plateformes comme Twitch et
|
||||
YouTube. Si vous choisissez de streamer des musiques copyrightées, votre VOD sera peut-être rendue muette. Dans le pire
|
||||
des cas, vous pourriez recevoir une plainte DMCA pour faire retirer la vidéo. Faites attention à streamer uniquement des
|
||||
musiques pour lesquelles vous avez le droit.
|
||||
@@ -1,10 +1,11 @@
|
||||
# A Link to the Past Randomizer Setup Guide
|
||||
|
||||
## Benötigte Software
|
||||
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Included in the above Utilities)
|
||||
- Hardware oder Software zum Laden und Abspielen von SNES Rom-Dateien
|
||||
- Ein Emulator, der lua-scripts abspielen kann
|
||||
- [SNI](https://github.com/alttpo/sni/releases) (Integriert in Archipelago)
|
||||
- Hardware oder Software zum Laden und Abspielen von SNES Rom-Dateien fähig zu einer Internetverbindung
|
||||
- Ein Emulator, der mit SNI verbinden kann
|
||||
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html))
|
||||
- Ein SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), oder andere kompatible Hardware
|
||||
@@ -13,44 +14,49 @@
|
||||
## Installation Schritt für Schritt
|
||||
|
||||
### Windows
|
||||
1. Lade die Multiworld Utilities herunter und führe die Installation aus. Sei sicher, dass du immer die
|
||||
aktuellste Version installiert hast.**Die Datei befindet sich im "assets"-Kasten unter der jeweiligen Versionsinfo!**.
|
||||
Für normale Multiworld-Spiele lädst du die `Setup.Archipelago.exe` herunter.
|
||||
- Für den Doorrandomizer muss die alternative doors-Variante geladen werden.
|
||||
- Während der Installation fragt dich das Programm nach der japanischen 1.0 ROM-Datei. Wenn du die Software
|
||||
bereits installiert hast und einfach nur updaten willst, wirst du nicht nochmal danach gefragt.
|
||||
- Es kann auch sein,dass der Installer Microsoft Visual C++ installieren möchte.
|
||||
Wenn du das bereits installiert hast (durch Steam oder andere Programme), wirst du nicht nochmal danach gefragt.
|
||||
|
||||
1. Lade die Multiworld Utilities herunter und führe die Installation aus. Sei sicher, dass du immer die aktuellste
|
||||
Version installiert hast.**Die Datei befindet sich im "assets"-Kasten unter der jeweiligen Versionsinfo!**. Für
|
||||
normale Multiworld-Spiele lädst du die `Setup.Archipelago.exe` herunter.
|
||||
- Für den Doorrandomizer muss die alternative doors-Variante geladen werden.
|
||||
- Während der Installation fragt dich das Programm nach der japanischen 1.0 ROM-Datei. Wenn du die Software bereits
|
||||
installiert hast und einfach nur updaten willst, wirst du nicht nochmal danach gefragt.
|
||||
- Es kann auch sein,dass der Installer Microsoft Visual C++ installieren möchte. Wenn du das bereits installiert
|
||||
hast (durch Steam oder andere Programme), wirst du nicht nochmal danach gefragt.
|
||||
|
||||
2. Wenn du einen Emulator benutzt, so ist es sinnvoll, ihn als Standard zum Abspielen für .sfc-dateien einzustellen.
|
||||
1. Entpacke oder Installiere deinen Emulator(-Ordner) an einen Ort, den du auch wiederfindest
|
||||
2. Rechtsklicke auf eine .sfc-Datei und wähle **Öffnen mit...**
|
||||
3. Mache einen Haken in die Box bei **Immer diese App zum Öffnen von .sfc Dateien benutzen **.
|
||||
4. Scrolle zum Ende und wähle **Weitere Apps** und nochmal am Ende **Andere App auf diesem PC suchen** auswählen.
|
||||
5. Suche nach der .exe-Datei des Emulators deiner Wahl und wähle **Öffnen**.
|
||||
Diese Datei befindet sich dort, wo den Emulator in Schritt 1 enpackt/installiert hast.
|
||||
5. Suche nach der .exe-Datei des Emulators deiner Wahl und wähle **Öffnen**. Diese Datei befindet sich dort, wo den
|
||||
Emulator in Schritt 1 enpackt/installiert hast.
|
||||
|
||||
### Macintosh
|
||||
|
||||
### Macintosh
|
||||
- Es werden freiwillige Helfer gesucht! Meldet euch doch bei **Farrak Kilhn** auf Discord, wenn ihr helfen wollt!
|
||||
|
||||
## Erstellen deiner YAML-Datei
|
||||
|
||||
### Was ist eine YAML-Datei und wofür brauche ich die?
|
||||
Deine persönliche YAML-Datei beinhaltet eine Reihe von Einstellungen, die der Zufallsgenerator zum Erstellen
|
||||
von deinem Spiel benötigt. Jeder Spieler einer Multiworld stellt seine eigene YAML-Datei zur Verfügung. Dadurch kann
|
||||
jeder Spieler sein Spiel nach seinem eigenen Geschmack gestalten, während andere Spieler unabhängig davon ihre eigenen
|
||||
Einstellungen wählen können!
|
||||
|
||||
Deine persönliche YAML-Datei beinhaltet eine Reihe von Einstellungen, die der Zufallsgenerator zum Erstellen von deinem
|
||||
Spiel benötigt. Jeder Spieler einer Multiworld stellt seine eigene YAML-Datei zur Verfügung. Dadurch kann jeder Spieler
|
||||
sein Spiel nach seinem eigenen Geschmack gestalten, während andere Spieler unabhängig davon ihre eigenen Einstellungen
|
||||
wählen können!
|
||||
|
||||
### Wo bekomme ich so eine YAML-Datei her?
|
||||
Die [Player Settings](/player-settings) Seite auf der Website ermöglicht das einfache Erstellen und Herunterladen
|
||||
deiner eigenen `yaml` Datei. Drei verschiedene Voreinstellungen können dort gespeichert werden.
|
||||
|
||||
Die [Player Settings](/games/A Link to the Past/player-settings) Seite auf der Website ermöglicht das einfache Erstellen
|
||||
und Herunterladen deiner eigenen `yaml` Datei. Drei verschiedene Voreinstellungen können dort gespeichert werden.
|
||||
|
||||
### Deine YAML-Datei ist gewichtet!
|
||||
Die **Player Settings** Seite hat eine Menge Optionen, die man per Schieber einstellen kann. Das ermöglicht es,
|
||||
verschiedene Optionen mit unterschiedlichen Wahrscheinlichkeiten in einer Kategorie ausgewürfelt zu werden
|
||||
|
||||
Als Beispiel kann man sich die Option "Map Shuffle" als einen Eimer mit Zetteln zur Abstimmung Vorstellen.
|
||||
So kann man beispielsweise für die Option "On" 20 Zettel mit dieser Option einwerfen und 40 Zettel mit "Off".
|
||||
Die **Player Settings** Seite hat eine Menge Optionen, die man per Schieber einstellen kann. Das ermöglicht es,
|
||||
verschiedene Optionen mit unterschiedlichen Wahrscheinlichkeiten in einer Kategorie ausgewürfelt zu werden
|
||||
|
||||
Als Beispiel kann man sich die Option "Map Shuffle" als einen Eimer mit Zetteln zur Abstimmung Vorstellen. So kann man
|
||||
beispielsweise für die Option "On" 20 Zettel mit dieser Option einwerfen und 40 Zettel mit "Off".
|
||||
|
||||
Entsprechend in diesem Beispiel liegen dann 60 Zettel im Eimer. 20 für "On" und 40 für "Off". Um die Option
|
||||
festzulegen, "greift" der Generator in den Eimer und holt sich zufällig einen Zettel heraus. Entsprechend ist die
|
||||
@@ -60,95 +66,99 @@ Wenn du eine Option nicht gewählt haben möchtest, setze ihren Wert einfach auf
|
||||
(Es muss aber mindestens eine Option pro Kategorie einen Wert größer Null besitzen, sonst funktioniert die yaml nicht!)
|
||||
|
||||
### Überprüfung deiner YAML-Datei
|
||||
Wenn man sichergehen will, ob die YAML-Datei funktioniert, kann man dies
|
||||
bei der [YAML Validator](/mysterycheck) Seite tun.
|
||||
|
||||
Wenn man sichergehen will, ob die YAML-Datei funktioniert, kann man dies bei der [YAML Validator](/mysterycheck) Seite
|
||||
tun.
|
||||
|
||||
## ein Einzelspielerspiel erstellen
|
||||
|
||||
1. Navigiere zur [Generator Seite](/generate) und lade dort deine YAML-Datei hoch.
|
||||
2. Dir wird eine "Seed Info"-Seite angezeigt, wo du deine Patch-Datei herunterladen kannst.
|
||||
3. Doppelklicke die Patchdatei und der Emulator sollte nach kurzer Verzögerung mit dem gepatchten Rom starten.
|
||||
Der Client ist soweit unnötig für Einzelspielerspiele, also kannst diesen und das WebUI einfach schließen.
|
||||
2. Dir wird eine "Seed Info"-Seite angezeigt, wo du deine Patch-Datei herunterladen kannst.
|
||||
3. Doppelklicke die Patchdatei und der Emulator sollte nach kurzer Verzögerung mit dem gepatchten Rom starten. Der
|
||||
Client ist soweit unnötig für Einzelspielerspiele, also kannst diesen und das WebUI einfach schließen.
|
||||
|
||||
## Einem MultiWorld-Spiel beitreten
|
||||
|
||||
### Erhalte deine Patch-Datei und erstelle dein ROM
|
||||
|
||||
Wenn du an einem MultiWorld-Spiel teilnehmen möchtest, wirst du in der Regel vom Host nach deiner YAML-Datei gefragt.
|
||||
Sobald du diese weitergegeben hast, wird der Host einen Link bereitstellen, wo du deinen Patch oder eine .zip-Datei
|
||||
mit allen Patches herunterladen kannst. Die Patch-Datei hat immer die Endung `.bmbp`.
|
||||
Sobald du diese weitergegeben hast, wird der Host einen Link bereitstellen, wo du deinen Patch oder eine .zip-Datei mit
|
||||
allen Patches herunterladen kannst. Die Patch-Datei hat immer die Endung `.apbp`.
|
||||
|
||||
### Mit dem Client verbinden
|
||||
|
||||
#### Via Emulator
|
||||
Wenn der client den Emulator automatisch gestartet hat, wird QUsb2Snes ebenfalls im Hintergrund gestartet.
|
||||
Wenn dies das erste Mal ist, wird möglicherweise ein Fenster angezeigt, wo man bestätigen muss, dass das Programm
|
||||
durch die Windows Firewall kommunizieren darf.
|
||||
|
||||
Wenn der client den Emulator automatisch gestartet hat, wird SNI ebenfalls im Hintergrund gestartet. Wenn dies das erste
|
||||
Mal ist, wird möglicherweise ein Fenster angezeigt, wo man bestätigen muss, dass das Programm durch die Windows Firewall
|
||||
kommunizieren darf.
|
||||
|
||||
##### snes9x Multitroid
|
||||
|
||||
1. Lade die Entsprechende ROM-Datei, wenn sie nicht schon automatisch geladen wurde.
|
||||
2. Klicke auf den Reiter "File" oben im Menü und wähle **Lua Scripting**
|
||||
3. Klicke auf **New Lua Script Window...**
|
||||
4. Im sich neu öffnenden Fenster, klicke auf **Browse...**
|
||||
5. Navigiere zum Ort, wo du snes9x Multitroid installiert hast, öffne den `lua`-Ordner und öffne `multibridge.lua`
|
||||
6. Schaue im Lua-Fenster nach einem Namen, der dir zugeteilt wird und schaue im Client (WebUI im Browser), ob dort
|
||||
5. Navigiere zum Verzeichnis, wo du Archipelago installiert hast und dort in den Unterordner `SNI`.
|
||||
6. Wähle dort die `Connector.lua` und klicke auf Öffnen.
|
||||
7. Schaue im Lua-Fenster nach einem Namen, der dir zugeteilt wird und schaue im Client (WebUI im Browser), ob dort
|
||||
"Snes Device: Connected" mit demselben Namen dort steht (in der oberen linken Ecke).
|
||||
|
||||
##### BizHawk
|
||||
1. Stelle sicher, dass der BSNES-Core in Bizhawk geladen wird. Dazu musst du auf das Tools-Menü in Bizhawk klicken
|
||||
und folgende Optionen wählen:
|
||||
|
||||
1. Stelle sicher, dass der BSNES-Core in Bizhawk geladen wird. Dazu musst du auf das Tools-Menü in Bizhawk klicken und
|
||||
folgende Optionen wählen:
|
||||
`Config --> Cores --> SNES --> BSNES`
|
||||
2. Lade die entsprechende ROM-Datei, wenn sie nicht schon automatisch geladen wurde.
|
||||
3. Klicke auf das Tools-Menü und klicke auf **Lua Console**
|
||||
4. Klicke auf den Button um ein neues Lua-Script zu öffnen.
|
||||
5. Navigiere zum Verzeichnis, wo du die Multiworld Utilities installiert hast und dort in folgende Ordner:
|
||||
`QUsb2Snes/Qusb2Snes/LuaBridge`
|
||||
6. Wähle dort die `luabridge.lua` und klicke auf Öffnen.
|
||||
5. Navigiere zum Verzeichnis, wo du Archipelago installiert hast und dort in den Unterordner `SNI`.
|
||||
6. Wähle dort die `Connector.lua` und klicke auf Öffnen.
|
||||
7. Schaue im Lua-Fenster nach einem Namen, der dir zugeteilt wird und schaue im Client (WebUI im Browser), ob dort
|
||||
"Snes Device: Connected" mit demselben Namen dort steht (in der oberen linken Ecke)
|
||||
|
||||
#### Mit (Original-)Hardware
|
||||
Dieser Guide setzt voraus, dass du schon die entsprechende Firmware für dein Gerät heruntergeladen hast! Wenn du
|
||||
das noch nicht getan hast, so tue dies am besten jetzt! SD2SNES und FXPak Pro Nutzer finden die passende Firmware
|
||||
|
||||
Dieser Guide setzt voraus, dass du schon die entsprechende Firmware für dein Gerät heruntergeladen hast! Wenn du das
|
||||
noch nicht getan hast, so tue dies am besten jetzt! SD2SNES und FXPak Pro Nutzer finden die passende Firmware
|
||||
[hier](https://github.com/RedGuyyyy/sd2snes/releases). Nutzer ähnlicher Hardware finden Hilfestellung
|
||||
[auf dieser Seite](http://usb2snes.com/#supported-platforms).
|
||||
|
||||
**UM MIT HARDWARE ZU VERBINDEN WIRD AKTUELL EINE ALTE VERSION VON QUSB2SNES BENÖTIGT
|
||||
([v0.7.16](https://github.com/Skarsnik/QUsb2snes/releases/tag/v0.7.16)).**
|
||||
Neuere Versionen funktionieren möglicherweise nur eingeschränkt, fehlerhaft oder gar nicht!
|
||||
|
||||
1. Schließe deinen Emulator, falls er automatisch gestartet haben sollte.
|
||||
2. Schließe QUsb2Snes, welches automatisch mit dem Client gestartet wurde (in der Taskleiste zu finden).
|
||||
3. Starte die richtige version von QUsb2Snes (v0.7.16).
|
||||
4. Starte deine (Original-)Konsole und lade die ROM-Datei.
|
||||
5. Schaue auf dein Clientfenster, welches nun "Snes Device: Connected" und den namen deiner Konsole
|
||||
zeigen sollte.
|
||||
2. Start SNI
|
||||
3. Starte deine (Original-)Konsole und lade die ROM-Datei.
|
||||
4. Schaue auf dein Clientfenster, welches nun "Snes Device: Connected" und den namen deiner Konsole zeigen sollte.
|
||||
|
||||
### Mit dem MultiServer verbinden
|
||||
|
||||
Die Patch-Datei, welche auch den Client gestartet hat, sollte dich automatisch mit dem MultiServer verbunden haben.
|
||||
Manchmal ist dies nicht der Fall, auch wenn das Spiel auf der Webseite gehostet wird, aber woanders erstellt wurde.
|
||||
Wenn die WebUI vom Client "Server Status: Not Connected" zeigt, frag deinen Host nach der passenden Adresse
|
||||
und trage sie einfach in das Textfeld neben "Server" ein und drücke Enter.
|
||||
Manchmal ist dies nicht der Fall, auch wenn das Spiel auf der Webseite gehostet wird, aber woanders erstellt wurde. Wenn
|
||||
die WebUI vom Client "Server Status: Not Connected" zeigt, frag deinen Host nach der passenden Adresse und trage sie
|
||||
einfach in das Textfeld neben "Server" ein und drücke Enter.
|
||||
|
||||
Der Client wird versuchen auf die neue Adresse zu verbinden und nach einer Weile "Server Status: Connected" zeigen.
|
||||
Sollte nach einer Weile der Client sich nicht verbunden haben, lade die Seite neu.
|
||||
|
||||
### Spiele das Spiel!
|
||||
Wenn der Client anzeigt, dass sowohl das SNES-Gerät (oder Emulator) und der Server verbunden sind,
|
||||
können du und deine Freunde loslegen! Glückwunsch zum erfolgreichen Beitritt zu einem Multiworld-Spiel ;)
|
||||
|
||||
Wenn der Client anzeigt, dass sowohl das SNES-Gerät (oder Emulator) und der Server verbunden sind, können du und deine
|
||||
Freunde loslegen! Glückwunsch zum erfolgreichen Beitritt zu einem Multiworld-Spiel ;)
|
||||
|
||||
## Ein Multiworld-Spiel hosten
|
||||
|
||||
Die Empfohlene Art, ein Spiel zu hosten, ist, den Service auf
|
||||
[der website](https://berserkermulti.world/generate) zu nutzen. Das Ganze ist recht einfach:
|
||||
[der website](/generate) zu nutzen. Das Ganze ist recht einfach:
|
||||
|
||||
1. Lasse dir von deinen Mitspielern die YAML-Datei zuschicken.
|
||||
2. Erstelle einen Zip-komprimierten Ordner´, in den du alle YAML-Dateien deiner Spieler einfügst.
|
||||
3. Lade diesen Zip-Ordner auf der oben genannten Website hoch.
|
||||
4. Warte einen Moment, wenn das Spiel erstellt wird.
|
||||
5. Wenn das Spiel erstellt wurde, wirst du auf eine "Seed Info"-Seite weitergeleitet.
|
||||
6. Klicke auf "Create New Room". Du wirst auf die Serverseite gebracht. Gib diesen Link deinen Mitspielern,
|
||||
sodass sie ihre Patch-Dateien von dort herunterladen können.
|
||||
**Anmerkung:** Die Patch-Dateien von dieser Seite ermöglichen es den Spielern,
|
||||
automatisch auf den Server zu verbinden. Die Patch-Dateien von der "Seed Info"-Seite tun dies nicht!
|
||||
7. Oben auf der Serverseite ist ein Link zum MultiWorld-Tracker zum aktuellen Spiel zu finden. Gib diesen Link
|
||||
ebenfalls deinen Mitspielern, so dass ihr alle den Fortschritt eures Spiels verfolgen könnt! Ihr könnt ihn
|
||||
auch an Zuschauer weitergeben, so dass sie auf dem Laufenden bleiben.
|
||||
6. Klicke auf "Create New Room". Du wirst auf die Serverseite gebracht. Gib diesen Link deinen Mitspielern, sodass sie
|
||||
ihre Patch-Dateien von dort herunterladen können.
|
||||
**Anmerkung:** Die Patch-Dateien von dieser Seite ermöglichen es den Spielern, automatisch auf den Server zu
|
||||
verbinden. Die Patch-Dateien von der "Seed Info"-Seite tun dies nicht!
|
||||
7. Oben auf der Serverseite ist ein Link zum MultiWorld-Tracker zum aktuellen Spiel zu finden. Gib diesen Link ebenfalls
|
||||
deinen Mitspielern, so dass ihr alle den Fortschritt eures Spiels verfolgen könnt! Ihr könnt ihn auch an Zuschauer
|
||||
weitergeben, so dass sie auf dem Laufenden bleiben.
|
||||
8. Wenn alle Spieler verbunden sind, könnt ihr mit dem Spiel loslegen! Viel Spaß!
|
||||
@@ -0,0 +1,159 @@
|
||||
# A Link to the Past Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- One of the client programs:
|
||||
- [SNIClient](https://github.com/ArchipelagoMW/Archipelago/releases), included with the main
|
||||
Archipelago install. Make sure to check the box for `SNI Client - A Link to the Past Patch Setup`
|
||||
- [SuperNintendoClient](https://github.com/ArchipelagoMW/SuperNintendoClient/releases), an alternate standalone
|
||||
client for Super Nintendo games
|
||||
- Hardware or software capable of loading and playing SNES ROM files
|
||||
- An emulator capable of connecting to SNI
|
||||
([snes9x rr](https://github.com/gocha/snes9x-rr/releases),
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html), or
|
||||
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 or newer). Or,
|
||||
- An SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
|
||||
- Your Japanese v1.0 ROM file, probably named `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
1. Download and install your preferred client from the link above, making sure to install the most recent version.
|
||||
**The installer file is located in the assets section at the bottom of the version information**.
|
||||
- During setup, you will be asked to locate your base ROM file. This is your Japanese Link to the Past ROM file.
|
||||
|
||||
2. 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.
|
||||
|
||||
## 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/A%20Link%20to%20the%20Past/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/A%20Link%20to%20the%20Past/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 Z3Client 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 `.apbp` 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 connector lua file included with your client
|
||||
- SuperNintendoClient users should download `sniConnector.lua` from the client download page
|
||||
- SNIClient users should look in their Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
|
||||
emulator is 64-bit or 32-bit.
|
||||
|
||||
##### 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 Script -> Open Script...
|
||||
5. Select the `Connector.lua` file you downloaded above
|
||||
- SuperNintendoClient users should download `sniConnector.lua` from the client download page
|
||||
- SNIClient users should look in their Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
|
||||
emulator is 64-bit or 32-bit.
|
||||
|
||||
##### RetroArch 1.10.1 or newer
|
||||
|
||||
You only have to do these steps once.
|
||||
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
When loading a ROM, be sure to select a **bsnes-mercury** core. These are the only cores that allow external tools to
|
||||
read ROM data.
|
||||
|
||||
#### 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! You can execute various commands in your client. For more information regarding
|
||||
these commands you can use `/help` for local client commands and `!help` for server commands.
|
||||
|
||||
## 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.
|
||||
@@ -7,97 +7,123 @@
|
||||
</div>
|
||||
|
||||
## Software requerido
|
||||
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Incluido en Multiworld Utilities)
|
||||
- Hardware o software capaz de cargar y ejecutar archivos de ROM de SNES
|
||||
- Un emulador capaz de ejecutar scripts Lua
|
||||
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html))
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html), o
|
||||
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 o más nuevo). O,
|
||||
- Un flashcart SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), o otro hardware compatible
|
||||
- Tu archivo ROM japones v1.0, probablemente se llame `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
||||
|
||||
## Procedimiento de instalación
|
||||
|
||||
### Instalación en Windows
|
||||
1. Descarga e instala MultiWorld Utilities desde el enlace anterior, asegurando que instalamos la versión más reciente.
|
||||
**El archivo esta localizado en la sección "assets" en la parte inferior de la información de versión**. Si tu intención es jugar la versión normal de multiworld, necesitarás el archivo `Setup.Archipelago.exe`
|
||||
- Si estas interesado en jugar la variante que aleatoriza las puertas internas de las mazmorras, necesitaras bajar 'Setup.BerserkerMultiWorld.Doors.exe'
|
||||
- Durante el proceso de instalación, se te pedirá donde esta situado tu archivo ROM japonés v1.0. Si ya habías instalado este software con anterioridad y simplemente estas actualizando, no se te pedirá la localización del archivo una segunda vez.
|
||||
- Puede ser que el programa pida la instalación de Microsoft Visual C++. Si ya lo tienes en tu ordenador (posiblemente por que un juego de Steam ya lo haya instalado), el instalador no te pedirá su instalación.
|
||||
|
||||
2. Si estas usando un emulador, deberías asignar la versión capaz de ejecutar scripts Lua como programa por defecto para lanzar ficheros de ROM de SNES.
|
||||
1. Descarga e instala MultiWorld Utilities desde el enlace anterior, asegurando que instalamos la versión más reciente.
|
||||
**El archivo esta localizado en la sección "assets" en la parte inferior de la información de versión**. Si tu
|
||||
intención es jugar la versión normal de multiworld, necesitarás el archivo `Setup.Archipelago.exe`
|
||||
- Si estas interesado en jugar la variante que aleatoriza las puertas internas de las mazmorras, necesitaras bajar '
|
||||
Setup.BerserkerMultiWorld.Doors.exe'
|
||||
- Durante el proceso de instalación, se te pedirá donde esta situado tu archivo ROM japonés v1.0. Si ya habías
|
||||
instalado este software con anterioridad y simplemente estas actualizando, no se te pedirá la localización del
|
||||
archivo una segunda vez.
|
||||
- Puede ser que el programa pida la instalación de Microsoft Visual C++. Si ya lo tienes en tu ordenador (
|
||||
posiblemente por que un juego de Steam ya lo haya instalado), el instalador no te pedirá su instalación.
|
||||
|
||||
2. Si estas usando un emulador, deberías asignar la versión capaz de ejecutar scripts Lua como programa por defecto para
|
||||
lanzar ficheros de ROM de SNES.
|
||||
1. Extrae tu emulador al escritorio, o cualquier sitio que después recuerdes.
|
||||
2. Haz click derecho en un fichero de ROM (ha de tener la extensión sfc) y selecciona **Abrir con...**
|
||||
3. Marca la opción **Usar siempre esta aplicación para abrir los archivos .sfc**
|
||||
4. Baja hasta el final de la lista y haz click en la opción **Buscar otra aplicación en el equipo** (Si usas Windows 10 es posible que debas hacer click en **Más aplicaciones**)
|
||||
5. Busca el archivo .exe de tu emulador y haz click en **Abrir**. Este archivo debe estar en el directorio donde extrajiste en el paso 1.
|
||||
4. Baja hasta el final de la lista y haz click en la opción **Buscar otra aplicación en el equipo** (Si usas Windows
|
||||
10 es posible que debas hacer click en **Más aplicaciones**)
|
||||
5. Busca el archivo .exe de tu emulador y haz click en **Abrir**. Este archivo debe estar en el directorio donde
|
||||
extrajiste en el paso 1.
|
||||
|
||||
### Instalación en Macintosh
|
||||
- ¡Necesitamos voluntarios para rellenar esta seccion! Contactad con **Farrak Kilhn** (en inglés) en Discord si queréis ayudar.
|
||||
|
||||
- ¡Necesitamos voluntarios para rellenar esta seccion! Contactad con **Farrak Kilhn** (en inglés) en Discord si queréis
|
||||
ayudar.
|
||||
|
||||
## Configurar tu archivo YAML
|
||||
|
||||
### Que es un archivo YAML y por qué necesito uno?
|
||||
Tu archivo YAML contiene un conjunto de opciones de configuración que proveen al generador con información sobre como debe generar tu juego.
|
||||
Cada jugador en una partida de multiworld proveerá su propio fichero YAML. Esta configuración permite
|
||||
que cada jugador disfrute de una experiencia personalizada a su gusto, y cada jugador dentro de la misma partida de multiworld puede tener diferentes opciones.
|
||||
|
||||
Tu archivo YAML contiene un conjunto de opciones de configuración que proveen al generador con información sobre como
|
||||
debe generar tu juego. Cada jugador en una partida de multiworld proveerá su propio fichero YAML. Esta configuración
|
||||
permite que cada jugador disfrute de una experiencia personalizada a su gusto, y cada jugador dentro de la misma partida
|
||||
de multiworld puede tener diferentes opciones.
|
||||
|
||||
### Donde puedo obtener un fichero YAML?
|
||||
La página "[Generate Game](/player-settings)" en el sitio web te permite configurar tu configuración personal y
|
||||
descargar un fichero "YAML".
|
||||
|
||||
La página "[Generate Game](/games/A%20Link%20to%20the%20Past/player-settings)" en el sitio web te permite configurar tu
|
||||
configuración personal y descargar un fichero "YAML".
|
||||
|
||||
### Configuración YAML avanzada
|
||||
|
||||
Una version mas avanzada del fichero Yaml puede ser creada usando la pagina ["Weighted settings"](/weighted-settings),
|
||||
la cual te permite tener almacenadas hasta 3 preajustes. La pagina "Weighted Settings" tiene muchas opciones representadas con controles deslizantes. Esto permite
|
||||
elegir cuan probable los valores de una categoría pueden ser elegidos sobre otros de la misma.
|
||||
la cual te permite tener almacenadas hasta 3 preajustes. La pagina "Weighted Settings" tiene muchas opciones
|
||||
representadas con controles deslizantes. Esto permite elegir cuan probable los valores de una categoría pueden ser
|
||||
elegidos sobre otros de la misma.
|
||||
|
||||
Por ejemplo, imagina que el generador crea un cubo llamado "map_shuffle", y pone trozos de papel doblado en él por cada sub-opción.
|
||||
Ademas imaginemos que tu valor elegido para "on" es 20 y el elegido para "off" es 40.
|
||||
Por ejemplo, imagina que el generador crea un cubo llamado "map_shuffle", y pone trozos de papel doblado en él por cada
|
||||
sub-opción. Ademas imaginemos que tu valor elegido para "on" es 20 y el elegido para "off" es 40.
|
||||
|
||||
Por tanto, en este ejemplo, habrán 60 trozos de papel. 20 para "on" y 40 para "off".
|
||||
Cuando el generador esta decidiendo si activar o no "map shuffle" para tu partida,
|
||||
meterá la mano en el cubo y sacara un trozo de papel al azar. En este ejemplo,
|
||||
es mucho mas probable (2 de cada 3 veces (40/60)) que "map shuffle" esté desactivado.
|
||||
Por tanto, en este ejemplo, habrán 60 trozos de papel. 20 para "on" y 40 para "off". Cuando el generador esta decidiendo
|
||||
si activar o no "map shuffle" para tu partida, meterá la mano en el cubo y sacara un trozo de papel al azar. En este
|
||||
ejemplo, es mucho mas probable (2 de cada 3 veces (40/60)) que "map shuffle" esté desactivado.
|
||||
|
||||
Si quieres que una opción no pueda ser escogida, simplemente asigna el valor 0 a dicha opción. Recuerda que cada opción debe tener
|
||||
al menos un valor mayor que cero, si no la generación fallará.
|
||||
Si quieres que una opción no pueda ser escogida, simplemente asigna el valor 0 a dicha opción. Recuerda que cada opción
|
||||
debe tener al menos un valor mayor que cero, si no la generación fallará.
|
||||
|
||||
### Verificando tu archivo YAML
|
||||
|
||||
Si quieres validar que tu fichero YAML para asegurarte que funciona correctamente, puedes hacerlo en la pagina
|
||||
[YAML Validator](/mysterycheck).
|
||||
|
||||
## Generar una partida para un jugador
|
||||
1. Navega a [la pagina Generate game](/player-settings), configura tus opciones, haz click en el boton "Generate game".
|
||||
|
||||
1. Navega a [la pagina Generate game](/games/A%20Link%20to%20the%20Past/player-settings), configura tus opciones, haz
|
||||
click en el boton "Generate game".
|
||||
2. Se te redigirá a una pagina "Seed Info", donde puedes descargar tu archivo de parche.
|
||||
3. Haz doble click en tu fichero de parche, y el emulador debería ejecutar tu juego automáticamente. Como el
|
||||
Cliente no es necesario para partidas de un jugador, puedes cerrarlo junto a la pagina web (que tiene como titulo "Multiworld WebUI") que se ha abierto automáticamente.
|
||||
3. Haz doble click en tu fichero de parche, y el emulador debería ejecutar tu juego automáticamente. Como el Cliente no
|
||||
es necesario para partidas de un jugador, puedes cerrarlo junto a la pagina web (que tiene como titulo "Multiworld
|
||||
WebUI") que se ha abierto automáticamente.
|
||||
|
||||
## Unirse a una partida MultiWorld
|
||||
|
||||
### Obtener el fichero de parche y crea tu ROM
|
||||
Cuando te unes a una partida multiworld, debes proveer tu fichero YAML a quien sea el creador de la partida. Una vez
|
||||
este hecho, el creador te devolverá un enlace para descargar el parche o un fichero zip conteniendo todos los ficheros de parche de la partida
|
||||
Tu fichero de parche debe tener la extensión `.bmbp`.
|
||||
|
||||
Pon tu fichero de parche en el escritorio o en algún sitio conveniente, y haz doble click. Esto debería ejecutar automáticamente
|
||||
el cliente, y ademas creara la rom en el mismo directorio donde este el fichero de parche.
|
||||
Cuando te unes a una partida multiworld, debes proveer tu fichero YAML a quien sea el creador de la partida. Una vez
|
||||
este hecho, el creador te devolverá un enlace para descargar el parche o un fichero zip conteniendo todos los ficheros
|
||||
de parche de la partida Tu fichero de parche debe tener la extensión `.bmbp`.
|
||||
|
||||
Pon tu fichero de parche en el escritorio o en algún sitio conveniente, y haz doble click. Esto debería ejecutar
|
||||
automáticamente el cliente, y ademas creara la rom en el mismo directorio donde este el fichero de parche.
|
||||
|
||||
### Conectar al cliente
|
||||
|
||||
#### Con emulador
|
||||
Cuando el cliente se lance automáticamente, QUsb2Snes debería haberse ejecutado también.
|
||||
Si es la primera vez que lo ejecutas, puedes ser que el firewall de Windows te pregunte si le permites la comunicación.
|
||||
|
||||
Cuando el cliente se lance automáticamente, QUsb2Snes debería haberse ejecutado también. Si es la primera vez que lo
|
||||
ejecutas, puedes ser que el firewall de Windows te pregunte si le permites la comunicación.
|
||||
|
||||
##### snes9x Multitroid
|
||||
|
||||
1. Carga tu fichero de ROM, si no lo has hecho ya
|
||||
2. Abre el menu "File" y situa el raton en **Lua Scripting**
|
||||
3. Haz click en **New Lua Script Window...**
|
||||
4. En la nueva ventana, haz click en **Browse...**
|
||||
5. Navega hacia el directorio donde este situado snes9x Multitroid, entra en el directorio `lua`, y escoge `multibridge.lua`
|
||||
6. Observa que se ha asignado un nombre al dispositivo, y el cliente muestra "SNES Device: Connected", con el mismo nombre
|
||||
en la esquina superior izquierda.
|
||||
5. Navega hacia el directorio donde este situado snes9x Multitroid, entra en el directorio `lua`, y
|
||||
escoge `multibridge.lua`
|
||||
6. Observa que se ha asignado un nombre al dispositivo, y el cliente muestra "SNES Device: Connected", con el mismo
|
||||
nombre en la esquina superior izquierda.
|
||||
|
||||
##### BizHawk
|
||||
|
||||
1. Asegurate que se ha cargado el nucleo BSNES. Debes hacer esto en el menu Tools y siguiento estas opciones:
|
||||
`Config --> Cores --> SNES --> BSNES`
|
||||
Una vez cambiado el nucleo cargado, Bizhawk ha de ser reiniciado.
|
||||
@@ -107,14 +133,31 @@ Si es la primera vez que lo ejecutas, puedes ser que el firewall de Windows te p
|
||||
5. Navega al directorio de instalación de MultiWorld Utilities, y en los siguiente directorios:
|
||||
`QUsb2Snes/Qusb2Snes/LuaBridge`
|
||||
6. Selecciona `luabridge.lua` y haz click en Abrir.
|
||||
7. Observa que se ha asignado un nombre al dispositivo, y el cliente muestra "SNES Device: Connected", con el mismo nombre
|
||||
en la esquina superior izquierda.
|
||||
7. Observa que se ha asignado un nombre al dispositivo, y el cliente muestra "SNES Device: Connected", con el mismo
|
||||
nombre en la esquina superior izquierda.
|
||||
|
||||
##### RetroArch 1.10.1 o más nuevo
|
||||
|
||||
Sólo hay que segiur estos pasos una vez.
|
||||
|
||||
1. Comienza en la pantalla del menú principal de RetroArch.
|
||||
2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON.
|
||||
3. Ve a Ajustes --> Red. Configura "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 (el
|
||||
default) el Puerto de comandos de red.
|
||||

|
||||
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
|
||||
SFC (bsnes-mercury Performance)".
|
||||
|
||||
Cuando cargas un ROM, asegúrate de seleccionar un núcleo **bsnes-mercury**. Estos son los sólos núcleos que permiten
|
||||
que herramientas externas lean datos del ROM.
|
||||
|
||||
#### Con Hardware
|
||||
Esta guía asume que ya has descargado el firmware correcto para tu dispositivo. Si no lo has hecho ya, hazlo ahora.
|
||||
Los usuarios de SD2SNES y FXPak Pro pueden descargar el firmware apropiado
|
||||
|
||||
Esta guía asume que ya has descargado el firmware correcto para tu dispositivo. Si no lo has hecho ya, hazlo ahora. Los
|
||||
usuarios de SD2SNES y FXPak Pro pueden descargar el firmware apropiado
|
||||
[aqui](https://github.com/RedGuyyyy/sd2snes/releases). Los usuarios de otros dispositivos pueden encontrar información
|
||||
[en esta página](http://usb2snes.com/#supported-platforms).
|
||||
|
||||
1. Cierra tu emulador, el cual debe haberse autoejecutado.
|
||||
2. Cierra QUsb2Snes, el cual fue ejecutado junto al cliente.
|
||||
3. Ejecuta la version correcta de QUsb2Snes (v0.7.16).
|
||||
@@ -122,19 +165,22 @@ Los usuarios de SD2SNES y FXPak Pro pueden descargar el firmware apropiado
|
||||
5. Observa en el cliente que ahora muestra "SNES Device: Connected", y aparece el nombre del dispositivo.
|
||||
|
||||
### Conecta al MultiServer
|
||||
El fichero de parche que ha lanzado el cliente debe haberte conectado automaticamente al MultiServer.
|
||||
Hay algunas razonas por las que esto puede que no pase, incluyendo que el juego este hospedado en el sitio web pero
|
||||
se genero en algún otro sitio. Si el cliente muestra "Server Status: Not Connected", preguntale al creador de la partida
|
||||
la dirección del servidor, copiala en el campo "Server" y presiona Enter.
|
||||
|
||||
El cliente intentara conectarse a esta nueva dirección, y debería mostrar "Server
|
||||
Status: Connected" en algún momento. Si el cliente no se conecta al cabo de un rato, puede ser que necesites refrescar la pagina web.
|
||||
El fichero de parche que ha lanzado el cliente debe haberte conectado automaticamente al MultiServer. Hay algunas
|
||||
razonas por las que esto puede que no pase, incluyendo que el juego este hospedado en el sitio web pero se genero en
|
||||
algún otro sitio. Si el cliente muestra "Server Status: Not Connected", preguntale al creador de la partida la dirección
|
||||
del servidor, copiala en el campo "Server" y presiona Enter.
|
||||
|
||||
El cliente intentara conectarse a esta nueva dirección, y debería mostrar "Server Status: Connected" en algún momento.
|
||||
Si el cliente no se conecta al cabo de un rato, puede ser que necesites refrescar la pagina web.
|
||||
|
||||
### Jugando
|
||||
Cuando ambos SNES Device and Server aparezcan como "connected", estas listo para empezar a jugar. Felicidades
|
||||
por unirte satisfactoriamente a una partida de multiworld!
|
||||
|
||||
Cuando ambos SNES Device and Server aparezcan como "connected", estas listo para empezar a jugar. Felicidades por unirte
|
||||
satisfactoriamente a una partida de multiworld!
|
||||
|
||||
## Hospedando una partida de multiworld
|
||||
|
||||
La manera recomendad para hospedar una partida es usar el servicio proveído en
|
||||
[el sitio web](/generate). El proceso es relativamente sencillo:
|
||||
|
||||
@@ -143,28 +189,34 @@ La manera recomendad para hospedar una partida es usar el servicio proveído en
|
||||
3. Carga el fichero zip en el sitio web enlazado anteriormente.
|
||||
4. Espera a que la seed sea generada.
|
||||
5. Cuando esto acabe, se te redigirá a una pagina titulada "Seed Info".
|
||||
6. Haz click en "Create New Room". Esto te llevara a la pagina del servidor. Pasa el enlace a esta pagina a los jugadores
|
||||
para que puedan descargar los ficheros de parche de ahi.
|
||||
6. Haz click en "Create New Room". Esto te llevara a la pagina del servidor. Pasa el enlace a esta pagina a los
|
||||
jugadores para que puedan descargar los ficheros de parche de ahi.
|
||||
**Nota:** Los ficheros de parche de esta pagina permiten a los jugadores conectarse al servidor automaticamente,
|
||||
mientras que los de la pagina "Seed info" no.
|
||||
7. Hay un enlace a un MultiWorld Tracker en la parte superior de la pagina de la sala. Deberías pasar también este enlace
|
||||
a los jugadores para que puedan ver el progreso de la partida. A los observadores también se les puede pasar este enlace.
|
||||
7. Hay un enlace a un MultiWorld Tracker en la parte superior de la pagina de la sala. Deberías pasar también este
|
||||
enlace a los jugadores para que puedan ver el progreso de la partida. A los observadores también se les puede pasar
|
||||
este enlace.
|
||||
8. Una vez todos los jugadores se han unido, podeis empezar a jugar.
|
||||
|
||||
## Auto-Tracking
|
||||
|
||||
Si deseas usar auto-tracking para tu partida, varios programas ofrecen esta funcionalidad.
|
||||
El programa recomentdado actualmente es:
|
||||
[OpenTracker](https://github.com/trippsc2/OpenTracker/releases).
|
||||
|
||||
### Instalación
|
||||
|
||||
1. Descarga el fichero de instalacion apropiado para tu ordenador (Usuarios de windows quieren el fichero ".msi").
|
||||
2. Durante el proceso de insatalación, puede que se te pida instalar Microsoft Visual Studio Build Tools. Un enlace
|
||||
este programa se muestra durante la proceso, y debe ser ejecutado manualmente.
|
||||
2. Durante el proceso de insatalación, puede que se te pida instalar Microsoft Visual Studio Build Tools. Un enlace este
|
||||
programa se muestra durante la proceso, y debe ser ejecutado manualmente.
|
||||
|
||||
### Activar auto-tracking
|
||||
1. Con OpenTracker ejecutado, haz click en el menu Tracking en la parte superior de la ventana, y elige **AutoTracker...**
|
||||
|
||||
1. Con OpenTracker ejecutado, haz click en el menu Tracking en la parte superior de la ventana, y elige **
|
||||
AutoTracker...**
|
||||
2. Click the **Get Devices** button
|
||||
3. Selecciona tu "SNES device" de la lista
|
||||
4. Si quieres que las llaves y los objetos de mazmorra tambien sean marcados, activa la caja con nombre **Race Illegal Tracking**
|
||||
4. Si quieres que las llaves y los objetos de mazmorra tambien sean marcados, activa la caja con nombre **Race Illegal
|
||||
Tracking**
|
||||
5. Haz click en el boton **Start Autotracking**
|
||||
6. Cierra la ventana AutoTracker, ya que deja de ser necesaria
|
||||
6. Cierra la ventana AutoTracker, ya que deja de ser necesaria
|
||||
@@ -7,102 +7,126 @@
|
||||
</div>
|
||||
|
||||
## Logiciels requis
|
||||
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Inclus dans les utilitaires précédents)
|
||||
- Une solution logicielle ou matérielle capable de charger et de lancer des fichiers ROM de SNES
|
||||
- Un émulateur capable d'éxécuter des scripts Lua
|
||||
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html))
|
||||
- Un SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), ou une autre solution matérielle compatible
|
||||
- Un SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), ou une autre solution matérielle
|
||||
compatible
|
||||
- Le fichier ROM de la v1.0 japonaise, sûrement nommé `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
||||
|
||||
## Procédure d'installation
|
||||
|
||||
### Installation sur Windows
|
||||
1. Téléchargez et installez les utilitaires du MultiWorld à l'aide du lien au-dessus, faites attention à bien installer la version la plus récente.
|
||||
**Le fichier se situe dans la section "assets" en bas des informations de version**. Si vous voulez jouer des parties classiques de multiworld,
|
||||
téléchargez `Setup.BerserkerMultiWorld.exe`
|
||||
- Si vous voulez jouer à la version alternative avec le mélangeur de portes dans les donjons, vous téléchargez le fichier
|
||||
`Setup.BerserkerMultiWorld.Doors.exe`.
|
||||
- Durant le processus d'installation, il vous sera demandé de localiser votre ROM v1.0 japonaise. Si vous avez déjà installé le logiciel
|
||||
auparavant et qu'il s'agit simplement d'une mise à jour, la localisation de la ROM originale ne sera pas requise.
|
||||
- Il vous sera peut-être également demandé d'installer Microsoft Visual C++. Si vous le possédez déjà (possiblement parce qu'un
|
||||
jeu Steam l'a déjà installé), l'installateur ne reproposera pas de l'installer.
|
||||
|
||||
2. Si vous utilisez un émulateur, il est recommandé d'assigner votre émulateur capable d'éxécuter des scripts Lua comme programme
|
||||
par défaut pour ouvrir vos ROMs.
|
||||
1. Extrayez votre dossier d'émulateur sur votre Bureau, ou à un endroit dont vous vous souviendrez.
|
||||
1. Téléchargez et installez les utilitaires du MultiWorld à l'aide du lien au-dessus, faites attention à bien installer
|
||||
la version la plus récente.
|
||||
**Le fichier se situe dans la section "assets" en bas des informations de version**. Si vous voulez jouer des parties
|
||||
classiques de multiworld, téléchargez `Setup.BerserkerMultiWorld.exe`
|
||||
- Si vous voulez jouer à la version alternative avec le mélangeur de portes dans les donjons, vous téléchargez le
|
||||
fichier
|
||||
`Setup.BerserkerMultiWorld.Doors.exe`.
|
||||
- Durant le processus d'installation, il vous sera demandé de localiser votre ROM v1.0 japonaise. Si vous avez déjà
|
||||
installé le logiciel auparavant et qu'il s'agit simplement d'une mise à jour, la localisation de la ROM originale
|
||||
ne sera pas requise.
|
||||
- Il vous sera peut-être également demandé d'installer Microsoft Visual C++. Si vous le possédez déjà (possiblement
|
||||
parce qu'un jeu Steam l'a déjà installé), l'installateur ne reproposera pas de l'installer.
|
||||
|
||||
2. Si vous utilisez un émulateur, il est recommandé d'assigner votre émulateur capable d'éxécuter des scripts Lua comme
|
||||
programme par défaut pour ouvrir vos ROMs.
|
||||
1. Extrayez votre dossier d'émulateur sur votre Bureau, ou à un endroit dont vous vous souviendrez.
|
||||
2. Faites un clic droit sur un fichier ROM et sélectionnez **Ouvrir avec...**
|
||||
3. Cochez la case à côté de **Toujours utiliser cette application pour ouvrir les fichiers .sfc**
|
||||
4. Descendez jusqu'en bas de la liste et sélectionnez **Rechercher une autre application sur ce PC**
|
||||
5. Naviguez dans les dossiers jusqu'au fichier `.exe` de votre émulateur et choisissez **Ouvrir**. Ce fichier devrait
|
||||
se trouver dans le dossier que vous avez extrait à la première étape.
|
||||
5. Naviguez dans les dossiers jusqu'au fichier `.exe` de votre émulateur et choisissez **Ouvrir**. Ce fichier
|
||||
devrait se trouver dans le dossier que vous avez extrait à la première étape.
|
||||
|
||||
### Installation sur Mac
|
||||
- Des volontaires sont recherchés pour remplir cette section ! Contactez **Farrak Kilhn** sur Discord si vous voulez aider.
|
||||
|
||||
- Des volontaires sont recherchés pour remplir cette section ! Contactez **Farrak Kilhn** sur Discord si vous voulez
|
||||
aider.
|
||||
|
||||
## Configurer son fichier YAML
|
||||
|
||||
### Qu'est-ce qu'un fichier YAML et pourquoi en ai-je besoin ?
|
||||
Votre fichier YAML contient un ensemble d'options de configuration qui fournissent au générateur des informations
|
||||
sur comment il devrait générer votre seed. Chaque joueur d'un multiwolrd devra fournir son propre fichier YAML. Cela permet à chaque
|
||||
joueur d'apprécier une expérience customisée selon ses goûts, et les différents joueurs d'un même multiworld peuvent avoir différentes options.
|
||||
|
||||
Votre fichier YAML contient un ensemble d'options de configuration qui fournissent au générateur des informations sur
|
||||
comment il devrait générer votre seed. Chaque joueur d'un multiwolrd devra fournir son propre fichier YAML. Cela permet
|
||||
à chaque joueur d'apprécier une expérience customisée selon ses goûts, et les différents joueurs d'un même multiworld
|
||||
peuvent avoir différentes options.
|
||||
|
||||
### Où est-ce que j'obtiens un fichier YAML ?
|
||||
La page [Génération de partie](/player-settings) vous permet de configurer vos paramètres personnels et de les exporter vers un fichier YAML.
|
||||
|
||||
La page [Génération de partie](/games/A%20Link%20to%20the%20Past/player-settings) vous permet de configurer vos
|
||||
paramètres personnels et de les exporter vers un fichier YAML.
|
||||
|
||||
### Configuration avancée du fichier YAML
|
||||
Une version plus avancée du fichier YAML peut être créée en utilisant la page des [paramètres de pondération](/weighted-settings), qui vous permet
|
||||
de configurer jusqu'à trois préréglages. Cette page a de nombreuses options qui sont essentiellement représentées avec des curseurs glissants.
|
||||
Cela vous permet de choisir quelles sont les chances qu'une certaine option apparaisse par rapport aux autres disponibles dans une même catégorie.
|
||||
|
||||
Une version plus avancée du fichier YAML peut être créée en utilisant la page
|
||||
des [paramètres de pondération](/weighted-settings), qui vous permet de configurer jusqu'à trois préréglages. Cette page
|
||||
a de nombreuses options qui sont essentiellement représentées avec des curseurs glissants. Cela vous permet de choisir
|
||||
quelles sont les chances qu'une certaine option apparaisse par rapport aux autres disponibles dans une même catégorie.
|
||||
|
||||
Par exemple, imaginez que le générateur crée un seau étiqueté "Mélange des cartes", et qu'il place un morceau de papier
|
||||
pour chaque sous-option. Imaginez également que la valeur pour "On" est 20 et la valeur pour "Off" est 40.
|
||||
|
||||
Dans cet exemple, il y a soixante morceaux de papier dans le seau : vingt pour "On" et quarante pour "Off". Quand le générateur
|
||||
décide s'il doit oui ou non activer le mélange des cartes pour votre partie, , il tire aléatoirement un papier dans le seau.
|
||||
Dans cet exemple, il y a de plus grandes chances d'avoir le mélange de cartes désactivé.
|
||||
Dans cet exemple, il y a soixante morceaux de papier dans le seau : vingt pour "On" et quarante pour "Off". Quand le
|
||||
générateur décide s'il doit oui ou non activer le mélange des cartes pour votre partie, , il tire aléatoirement un
|
||||
papier dans le seau. Dans cet exemple, il y a de plus grandes chances d'avoir le mélange de cartes désactivé.
|
||||
|
||||
S'il y a une option dont vous ne voulez jamais, mettez simplement sa valeur à zéro. N'oubliez pas qu'il faut que pour chaque paramètre il faut
|
||||
au moins une option qui soit paramétrée sur un nombre strictement positif.
|
||||
S'il y a une option dont vous ne voulez jamais, mettez simplement sa valeur à zéro. N'oubliez pas qu'il faut que pour
|
||||
chaque paramètre il faut au moins une option qui soit paramétrée sur un nombre strictement positif.
|
||||
|
||||
### Vérifier son fichier YAML
|
||||
|
||||
Si vous voulez valider votre fichier YAML pour être sûr qu'il fonctionne, vous pouvez le vérifier sur la page du
|
||||
[Validateur de YAML](/mysterycheck).
|
||||
|
||||
## Générer une partie pour un joueur
|
||||
1. Aller sur la page [Génération de partie](/player-settings), configurez vos options, et cliquez sur le bouton "Generate Game".
|
||||
|
||||
1. Aller sur la page [Génération de partie](/games/A%20Link%20to%20the%20Past/player-settings), configurez vos options,
|
||||
et cliquez sur le bouton "Generate Game".
|
||||
2. Il vous sera alors présenté une page d'informations sur la seed, où vous pourrez télécharger votre patch.
|
||||
3. Double-cliquez sur le patch et l'émulateur devrait se lancer automatiquement avec la seed. Etant donné que le client
|
||||
n'est pas requis pour les parties à un joueur, vous pouvez le fermer ainsi que l'interface Web (WebUI).
|
||||
n'est pas requis pour les parties à un joueur, vous pouvez le fermer ainsi que l'interface Web (WebUI).
|
||||
|
||||
## Rejoindre un MultiWorld
|
||||
|
||||
### Obtenir son patch et créer sa ROM
|
||||
Quand vous rejoignez un multiworld, il vous sera demandé de fournir votre fichier YAML à celui qui héberge la partie
|
||||
ou s'occupe de la génération. Une fois cela fait, l'hôte vous fournira soit un lien pour télécharger votre patch, soit un fichier `.zip` contenant
|
||||
les patchs de tous les joueurs. Votre patch devrait avoir l'extension `.bmbp`.
|
||||
|
||||
Placez votre patch sur votre bureau ou dans un dossier simple d'accès, et double-cliquez dessus. Cela devrait lancer automatiquement
|
||||
le client, et devrait créer la ROM dans le même dossier que votre patch.
|
||||
Quand vous rejoignez un multiworld, il vous sera demandé de fournir votre fichier YAML à celui qui héberge la partie ou
|
||||
s'occupe de la génération. Une fois cela fait, l'hôte vous fournira soit un lien pour télécharger votre patch, soit un
|
||||
fichier `.zip` contenant les patchs de tous les joueurs. Votre patch devrait avoir l'extension `.bmbp`.
|
||||
|
||||
Placez votre patch sur votre bureau ou dans un dossier simple d'accès, et double-cliquez dessus. Cela devrait lancer
|
||||
automatiquement le client, et devrait créer la ROM dans le même dossier que votre patch.
|
||||
|
||||
### Se connecter au client
|
||||
|
||||
#### Avec un émulateur
|
||||
Quand le client se lance automatiquement, QUsb2Snes devrait se lancer automatiquement également en arrière-plan.
|
||||
Si c'est la première fois qu'il démarre, il vous sera peut-être demandé de l'autoriser à communiquer
|
||||
à travers le pare-feu Windows.
|
||||
|
||||
Quand le client se lance automatiquement, QUsb2Snes devrait se lancer automatiquement également en arrière-plan. Si
|
||||
c'est la première fois qu'il démarre, il vous sera peut-être demandé de l'autoriser à communiquer à travers le pare-feu
|
||||
Windows.
|
||||
|
||||
##### snes9x Multitroid
|
||||
|
||||
1. Chargez votre ROM si ce n'est pas déjà fait.
|
||||
2. Cliquez sur le menu "File" et survolez l'option **Lua Scripting**
|
||||
3. Cliquez alors sur **New Lua Script Window...**
|
||||
4. Dans la nouvelle fenêtre, sélectionnez **Browse...**
|
||||
5. Dirigez vous vers le dossier où vous avez extrait snes9x Multitroid, allez dans le dossier `lua`, puis choisissez `multibridge.lua`
|
||||
6. Remarquez qu'un nom vous a été assigné, et que l'interface Web affiche "SNES Device: Connected", avec ce même nom dans le coin en haut à gauche.
|
||||
5. Dirigez vous vers le dossier où vous avez extrait snes9x Multitroid, allez dans le dossier `lua`, puis
|
||||
choisissez `multibridge.lua`
|
||||
6. Remarquez qu'un nom vous a été assigné, et que l'interface Web affiche "SNES Device: Connected", avec ce même nom
|
||||
dans le coin en haut à gauche.
|
||||
|
||||
##### BizHawk
|
||||
1. Assurez vous d'avoir le coeur BSNES chargé. Cela est possible en cliquant sur le menu "Tools" de BizHawk et suivant ces options de menu :
|
||||
|
||||
1. Assurez vous d'avoir le coeur BSNES chargé. Cela est possible en cliquant sur le menu "Tools" de BizHawk et suivant
|
||||
ces options de menu :
|
||||
`Config --> Cores --> SNES --> BSNES`
|
||||
Une fois le coeur changé, vous devez redémarrer BizHawk.
|
||||
2. Chargez votre ROM si ce n'est pas déjà fait.
|
||||
@@ -111,11 +135,13 @@ Si c'est la première fois qu'il démarre, il vous sera peut-être demandé de l
|
||||
5. Dirigez vous vers le dossier d'installation des utilitaires du MultiWorld, puis dans les dossiers suivants :
|
||||
`QUsb2Snes/Qusb2Snes/LuaBridge`
|
||||
6. Sélectionnez `luabridge.lua` et cliquez sur "Open".
|
||||
7. Remarquez qu'un nom vous a été assigné, et que l'interface Web affiche "SNES Device: Connected", avec ce même nom dans le coin en haut à gauche.
|
||||
7. Remarquez qu'un nom vous a été assigné, et que l'interface Web affiche "SNES Device: Connected", avec ce même nom
|
||||
dans le coin en haut à gauche.
|
||||
|
||||
#### Avec une solution matérielle
|
||||
Ce guide suppose que vous avez télchargé le bon micro-logiciel pour votre appareil. Si ce n'est pas déjà le cas, faites le maintenant.
|
||||
Les utilisateurs de SD2SNES et de FXPak Pro peuvent télécharger le micro-logiciel approprié
|
||||
|
||||
Ce guide suppose que vous avez télchargé le bon micro-logiciel pour votre appareil. Si ce n'est pas déjà le cas, faites
|
||||
le maintenant. Les utilisateurs de SD2SNES et de FXPak Pro peuvent télécharger le micro-logiciel approprié
|
||||
[ici](https://github.com/RedGuyyyy/sd2snes/releases). Pour les autres solutions, de l'aide peut être trouvée
|
||||
[sur cette page](http://usb2snes.com/#supported-platforms).
|
||||
|
||||
@@ -126,20 +152,24 @@ Les utilisateurs de SD2SNES et de FXPak Pro peuvent télécharger le micro-logic
|
||||
5. Remarquez que l'interface Web affiche "SNES Device: Connected", avec le nom de votre appareil.
|
||||
|
||||
### Se connecter au MultiServer
|
||||
Le patch qui a lancé le client devrait vous avoir connecté automatiquement au MultiServer.
|
||||
Il y a cependant quelques cas où cela peut ne pas se produire, notamment si le multiworld est hébergé sur ce site, mais a été généré ailleurs.
|
||||
Si l'interface Web affiche "Server Status: Not Connected", demandez simplement à l'hôte l'adresse du serveur,
|
||||
et copiez/collez la dans le champ "Server" puis appuyez sur Entrée.
|
||||
|
||||
Le client essaiera de vous reconnecter à la nouvelle adresse du serveur, et devrait mentionner "Server
|
||||
Status: Connected". Si le client ne se connecte pas après quelques instants, il faudra peut-être rafraîchir la page de l'interface Web.
|
||||
Le patch qui a lancé le client devrait vous avoir connecté automatiquement au MultiServer. Il y a cependant quelques cas
|
||||
où cela peut ne pas se produire, notamment si le multiworld est hébergé sur ce site, mais a été généré ailleurs. Si
|
||||
l'interface Web affiche "Server Status: Not Connected", demandez simplement à l'hôte l'adresse du serveur, et
|
||||
copiez/collez la dans le champ "Server" puis appuyez sur Entrée.
|
||||
|
||||
Le client essaiera de vous reconnecter à la nouvelle adresse du serveur, et devrait mentionner "Server Status:
|
||||
Connected". Si le client ne se connecte pas après quelques instants, il faudra peut-être rafraîchir la page de
|
||||
l'interface Web.
|
||||
|
||||
### Jouer au jeu
|
||||
|
||||
Une fois que l'interface Web affiche que la SNES et le serveur sont connectés, vous êtes prêt à jouer. Félicitations
|
||||
pour avoir rejoint un multiworld !
|
||||
|
||||
## Héberger un MultiWorld
|
||||
La méthode recommandée pour héberger une partie est d'utiliser le service d'hébergement fourni par
|
||||
|
||||
La méthode recommandée pour héberger une partie est d'utiliser le service d'hébergement fourni par
|
||||
[le site](https://berserkermulti.world/generate). Le processus est relativement simple :
|
||||
|
||||
1. Récupérez les fichiers YAML des joueurs.
|
||||
@@ -147,26 +177,32 @@ La méthode recommandée pour héberger une partie est d'utiliser le service d'h
|
||||
3. Téléversez l'archive zip sur le lien ci-dessus.
|
||||
4. Attendez un moment que les seed soient générées.
|
||||
5. Lorsque les seeds sont générées, vous serez redirigé vers une page d'informations "Seed Info".
|
||||
6. Cliquez sur "Create New Room". Cela vous amènera à la page du serveur. Fournissez le lien de cette page aux autres joueurs
|
||||
afin qu'ils puissent récupérer leurs patchs.
|
||||
6. Cliquez sur "Create New Room". Cela vous amènera à la page du serveur. Fournissez le lien de cette page aux autres
|
||||
joueurs afin qu'ils puissent récupérer leurs patchs.
|
||||
**Note:** Les patchs fournis sur cette page permettront aux joueurs de se connecteur automatiquement au serveur,
|
||||
tandis que ceux de la page "Seed Info" non.
|
||||
7. Remarquez qu'un lien vers le traqueur du MultiWorld est en haut de la page de la salle. Vous devriez également fournir ce lien aux joueurs
|
||||
pour qu'ils puissent suivre la progression de la partie. N'importe quel personne voulant observer devrait avoir accès à ce lien.
|
||||
7. Remarquez qu'un lien vers le traqueur du MultiWorld est en haut de la page de la salle. Vous devriez également
|
||||
fournir ce lien aux joueurs pour qu'ils puissent suivre la progression de la partie. N'importe quel personne voulant
|
||||
observer devrait avoir accès à ce lien.
|
||||
8. Une fois que tous les joueurs ont rejoint, vous pouvez commencer à jouer.
|
||||
|
||||
## Auto-tracking
|
||||
|
||||
Si vous voulez utiliser l'auto-tracking, plusieurs logiciels offrent cette possibilité.
|
||||
Le logiciel recommandé pour l'auto-tracking actuellement est
|
||||
[OpenTracker](https://github.com/trippsc2/OpenTracker/releases).
|
||||
|
||||
### Installation
|
||||
1. Téléchargez le fichier d'installation approprié pour votre ordinateur (Les utilisateurs Windows voudront le fichier `.msi`).
|
||||
2. Durant le processus d'installation, il vous sera peut-être demandé d'installer les outils "Microsoft Visual Studio Build Tools". Un
|
||||
lien est fourni durant l'installation d'OpenTracker, et celle des outils doit se faire manuellement.
|
||||
|
||||
|
||||
1. Téléchargez le fichier d'installation approprié pour votre ordinateur (Les utilisateurs Windows voudront le
|
||||
fichier `.msi`).
|
||||
2. Durant le processus d'installation, il vous sera peut-être demandé d'installer les outils "Microsoft Visual Studio
|
||||
Build Tools". Un lien est fourni durant l'installation d'OpenTracker, et celle des outils doit se faire manuellement.
|
||||
|
||||
### Activer l'auto-tracking
|
||||
1. Une fois OpenTracker démarré, cliquez sur le menu "Tracking" en haut de la fenêtre, puis choisissez **AutoTracker...**
|
||||
|
||||
1. Une fois OpenTracker démarré, cliquez sur le menu "Tracking" en haut de la fenêtre, puis choisissez **
|
||||
AutoTracker...**
|
||||
2. Appuyez sur le bouton **Get Devices**
|
||||
3. Sélectionnez votre appareil SNES dans la liste déroulante.
|
||||
4. Si vous voulez tracquer les petites clés ainsi que les objets des donjons, cochez la case **Race Illegal Tracking**
|
||||
@@ -1,25 +1,24 @@
|
||||
# A Link to the Past Randomizer Plando Guide
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Plando features have to be enabled first, before they can be used (opt-in).
|
||||
2. To do so, go to your installation directory (Windows default: `C:\ProgramData\Archipelago`),
|
||||
then open the host.yaml file with a text editor.
|
||||
3. In it, you're looking for the option key `plando_options`. To enable all plando modules you can set the
|
||||
value to
|
||||
`bosses, items, texts, connections`
|
||||
2. To do so, go to your installation directory (Windows default: `C:\ProgramData\Archipelago`), then open the host.yaml
|
||||
file with a text editor.
|
||||
3. In it, you're looking for the option key `plando_options`. To enable all plando modules you can set the value
|
||||
to `bosses, items, texts, connections`
|
||||
|
||||
## Modules
|
||||
|
||||
### Bosses
|
||||
|
||||
- This module is enabled by default and available to be used on
|
||||
[https://archipelago.gg/generate](/generate)
|
||||
- This module is enabled by default and available to be used on [https://archipelago.gg/generate](/generate)
|
||||
- Plando versions of boss shuffles can be added like any other boss shuffle option in a yaml and weighted.
|
||||
- Boss Plando works as a list of instructions from left to right, if any arenas are empty at the end,
|
||||
it defaults to vanilla
|
||||
- Instructions are separated by a semicolon
|
||||
- Boss Plando works as a list of instructions from left to right, if any arenas are empty at the end, it defaults to
|
||||
vanilla.
|
||||
- Instructions are separated by a semicolon.
|
||||
- Available Instructions:
|
||||
- Direct Placement:
|
||||
- Direct Placement:
|
||||
- Example: `Eastern Palace-Trinexx`
|
||||
- Takes a particular Arena and particular boss, then places that boss into that arena
|
||||
- Ganons Tower has 3 placements, `Ganons Tower Top`, `Ganons Tower Middle` and `Ganons Tower Bottom`
|
||||
@@ -29,12 +28,13 @@
|
||||
- In this example, it would fill Desert Palace, but not Tower of Hera.
|
||||
- Boss Shuffle:
|
||||
- Example: `simple`
|
||||
- Runs a particular boss shuffle mode to finish construction instead of vanilla placement, typically used as
|
||||
a last instruction.
|
||||
- [Available Bosses](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Bosses.py#L135)
|
||||
- [Available Arenas](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Bosses.py#L186)
|
||||
- Runs a particular boss shuffle mode to finish construction instead of vanilla placement, typically used as
|
||||
a last instruction.
|
||||
- [Available Bosses](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Bosses.py#L135)
|
||||
- [Available Arenas](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Bosses.py#L150)
|
||||
|
||||
#### Examples
|
||||
|
||||
```yaml
|
||||
boss_shuffle:
|
||||
Turtle Rock-Trinexx;basic: 1
|
||||
@@ -42,14 +42,15 @@ boss_shuffle:
|
||||
Mothula: 3
|
||||
Ganons Tower Bottom-Kholdstare;Trinexx;Kholdstare: 4
|
||||
```
|
||||
1. Would be basic boss shuffle but prevent Trinexx from appearing outside of Turtle Rock,
|
||||
as there's only one Trinexx in the pool
|
||||
|
||||
1. Would be basic boss shuffle but prevent Trinexx from appearing outside of Turtle Rock, as there's only one Trinexx in
|
||||
the pool
|
||||
2. Regular full boss shuffle. With a 2 in 10 chance to occur.
|
||||
3. A Mothula Singularity, as Mothula works in any arena.
|
||||
4. A Trinexx -> Kholdstare Singularity that prevents ice Trinexx in GT
|
||||
|
||||
|
||||
### Items
|
||||
|
||||
- This module is disabled by default.
|
||||
- Has the options from_pool, world, percentage, force and either item and location or items and locations
|
||||
- All of these options support subweights
|
||||
@@ -77,12 +78,13 @@ boss_shuffle:
|
||||
- items denotes the items to use, can be given a number to have multiple of that item
|
||||
- locations lists the possible locations those items can be placed in
|
||||
- placements are picked randomly, not sorted in any way
|
||||
- Warning: Placing non-Dungeon Prizes on Prize locations and
|
||||
Prizes on non-Prize locations will break the game in various ways.
|
||||
- [Available Items](https://github.com/Berserker66/MultiWorld-Utilities/blob/3b5ba161dea223b96e9b1fc890e03469d9c6eb59/Items.py#L26)
|
||||
- [Available Locations](https://github.com/Berserker66/MultiWorld-Utilities/blob/3b5ba161dea223b96e9b1fc890e03469d9c6eb59/Regions.py#L418)
|
||||
- Warning: Placing non-Dungeon Prizes on Prize locations and Prizes on non-Prize locations will break the game in
|
||||
various ways.
|
||||
- [Available Items](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Items.py#L52)
|
||||
- [Available Locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Regions.py#L434)
|
||||
|
||||
#### Examples
|
||||
|
||||
```yaml
|
||||
plando_items:
|
||||
- item: # 1
|
||||
@@ -119,26 +121,28 @@ plando_items:
|
||||
from_pool: true
|
||||
```
|
||||
|
||||
1. has a 50% chance to occur, which if it does places either the Lamp or Fire Rod in one's own
|
||||
Link's House and removes the picked item from the item pool.
|
||||
1. has a 50% chance to occur, which if it does places either the Lamp or Fire Rod in one's own Link's House and removes
|
||||
the picked item from the item pool.
|
||||
2. Always triggers and places the Swords and Bows into one's own Big Chests
|
||||
3. Locks Pendants to The Light World and therefore Crystals to dark world
|
||||
|
||||
### Texts
|
||||
|
||||
- This module is disabled by default.
|
||||
- Has the options `text`, `at`, and `percentage`
|
||||
- percentage is the percentage chance for this text to be placed, can be omitted entirely for 100%
|
||||
- text is the text to be placed.
|
||||
- can be weighted.
|
||||
- `\n` is a newline.
|
||||
- `\n` is a newline.
|
||||
- `@` is the entered player's name.
|
||||
- Warning: Text Mapper does not support full unicode.
|
||||
- [Alphabet](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L756)
|
||||
- [Alphabet](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L758)
|
||||
- at is the location within the game to attach the text to.
|
||||
- can be weighted.
|
||||
- [List of targets](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L1498)
|
||||
|
||||
- [List of targets](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L1499)
|
||||
|
||||
#### Example
|
||||
|
||||
```yaml
|
||||
plando_texts:
|
||||
- text: "This is a plando.\nYou've been warned."
|
||||
@@ -147,11 +151,13 @@ plando_texts:
|
||||
uncle_dying_sewer: 1
|
||||
percentage: 50
|
||||
```
|
||||

|
||||
This has a 50% chance to trigger at all. If it does, it throws a coin between `uncle_leaving_text` and
|
||||
`uncle_dying_sewer`, then places the text "This is a plando. You've been warned." at that location.
|
||||
|
||||

|
||||
This has a 50% chance to trigger at all. If it does, it throws a coin between `uncle_leaving_text`
|
||||
and `uncle_dying_sewer`, then places the text "This is a plando. You've been warned." at that location.
|
||||
|
||||
### Connections
|
||||
|
||||
- This module is disabled by default.
|
||||
- Has the options `percentage`, `entrance`, `exit` and `direction`.
|
||||
- All options support subweights
|
||||
@@ -160,10 +166,11 @@ This has a 50% chance to trigger at all. If it does, it throws a coin between `u
|
||||
- entrance is the overworld door
|
||||
- exit is the underworld exit
|
||||
- direction can be `both`, `entrance` or `exit`
|
||||
- doors can be found in [this file](https://github.com/Berserker66/MultiWorld-Utilities/blob/main/EntranceShuffle.py)
|
||||
|
||||
- doors can be found
|
||||
in [this file](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/EntranceShuffle.py#L3852)
|
||||
|
||||
#### Example
|
||||
|
||||
```yaml
|
||||
plando_connections:
|
||||
- entrance: Links House
|
||||
@@ -174,8 +181,8 @@ plando_connections:
|
||||
direction: both
|
||||
```
|
||||
|
||||
The first block connects the overworld entrance that normally leads to Link's House
|
||||
to put you into the HC West Wing instead, exiting from within there will put you at the Overworld exiting Link's House.
|
||||
The first block connects the overworld entrance that normally leads to Link's House to put you into the HC West Wing
|
||||
instead, exiting from within there will put you at the Overworld exiting Link's House.
|
||||
|
||||
Without the second block, you'd still exit from within Link's House to outside Link's House and the left side
|
||||
Balcony Entrance would still lead into HC West Wing
|
||||
Without the second block, you'd still exit from within Link's House to outside Link's House and the left side Balcony
|
||||
Entrance would still lead into HC West Wing
|
||||
12
WebHostLib/static/assets/tutorial/ArchipIDLE/guide_en.md
Normal file
12
WebHostLib/static/assets/tutorial/ArchipIDLE/guide_en.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# ArchipIdle Setup Guide
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
1. Generate a `.yaml` file from the [ArchipIDLE Player Settings Page](/games/ArchipIDLE/player-settings)
|
||||
2. Open the ArchipIDLE Client in your web browser by either:
|
||||
- Navigate to the [ArchipIDLE Client](http://idle.multiworld.link)
|
||||
- Download the client and run it locally from the
|
||||
[ArchipIDLE GitHub Releases Page](https://github.com/ArchipelagoMW/archipidle/releases)
|
||||
3. Enter the server address in the `Server Address` field and press enter
|
||||
4. Enter your slot name when prompted. This should be the same as the `name` you entered on the
|
||||
setting page above, or the `name` field in your yaml file.
|
||||
5. Click the "Begin!" button.
|
||||
@@ -0,0 +1,221 @@
|
||||
# Advanced YAML Guide
|
||||
|
||||
This guide covers more the more advanced options available in YAML files. This guide is intended for the user who is intent on editing their YAML file manually. This guide should take about 10 minutes to read.
|
||||
|
||||
If you would like to generate a basic, fully playable, YAML without editing a file then visit the settings page for the game you intend to play. The weighted settings page can also handle most of the advanced settings discussed here.
|
||||
|
||||
The settings page can be found on the supported games page, just click the "Settings Page" link under the name of the game you would like.
|
||||
* Supported games page: [Archipelago Games List](/games)
|
||||
* Weighted settings page: [Archipelago Weighted Settings](/weighted-settings)
|
||||
|
||||
Clicking on the "Export Settings" button at the bottom-left will provide you with a pre-filled YAML with your options.
|
||||
The player settings page also has a link to download a full template file for that game which will have every option possible for the game including some that don't display correctly on the site.
|
||||
|
||||
## YAML Overview
|
||||
|
||||
The Archipelago system generates games using player configuration files as input. These are going to be YAML files and
|
||||
each world will have one of these containing their custom settings for the game that world will play.
|
||||
|
||||
## YAML Formatting
|
||||
|
||||
YAML files are a format of human-readable config files. The basic syntax of a yaml file will have a `root` node and then
|
||||
different levels of `nested` nodes that the generator reads in order to determine your settings.
|
||||
|
||||
To nest text, the correct syntax is to indent **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. Sublime text
|
||||
website: [SublimeText Website](https://www.sublimetext.com)
|
||||
|
||||
This program out of the box supports the correct formatting for the YAML file, so you will be able to use the tab key
|
||||
and get proper highlighting for any potential errors made while editing the file. If using any other text editor you
|
||||
should ensure your indentation is done correctly with two spaces.
|
||||
|
||||
A typical YAML file will look like:
|
||||
|
||||
```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.
|
||||
* `locations` will guarantee all locations are accessible in your world.
|
||||
* `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 triggers in the triggers guide. Triggers
|
||||
guide: [Archipelago Triggers Guide](/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. 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.
|
||||
|
||||
See the plando guide for more info on plando options. Plando
|
||||
guide: [Archipelago Plando Guide](/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.
|
||||
|
||||
### Random numbers
|
||||
|
||||
Options taking a choice of a number can also use a variety of `random` options to choose a number randomly.
|
||||
|
||||
* `random` will choose a number allowed for the setting at random
|
||||
* `random-low` will choose a number allowed for the setting at random, but will be weighted towards lower numbers
|
||||
* `random-middle` will choose a number allowed for the setting at random, but will be weighted towards the middle of the
|
||||
range
|
||||
* `random-high` will choose a number allowed for the setting at random, but will be weighted towards higher numbers
|
||||
* `random-range-#-#` will choose a number at random from between the specified numbers. For example `random-range-40-60`
|
||||
will choose a number between 40 and 60
|
||||
* `random-range-low-#-#`, `random-range-middle-#-#`, and `random-range-high-#-#` will choose a number at random from the
|
||||
specified numbers, but with the specified weights
|
||||
|
||||
### 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
|
||||
crystals_needed_for_gt:
|
||||
random-low: 1
|
||||
crystals_needed_for_ganon:
|
||||
random-range-high-1-7: 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.
|
||||
* `crystals_needed_for_gt` determines the number of crystals required to enter the Ganon's Tower entrance. In this
|
||||
example a random number will be chosen from the allowed range for this setting (0 through 7) but will be weighted
|
||||
towards a lower number.
|
||||
* `crystals_needed_for_ganon` determines the number of crystals required to beat Ganon. In this example a number between
|
||||
1 and 7 will be chosen at random, weighted towards a high number.
|
||||
* `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.
|
||||
96
WebHostLib/static/assets/tutorial/Archipelago/commands_en.md
Normal file
96
WebHostLib/static/assets/tutorial/Archipelago/commands_en.md
Normal file
@@ -0,0 +1,96 @@
|
||||
### Helpful Commands
|
||||
|
||||
Commands are split into two types: client commands and server commands. Client commands are commands which are executed
|
||||
by the client and do not affect the Archipelago remote session. Server commands are commands which are executed by the
|
||||
Archipelago server and affect the Archipelago session or otherwise provide feedback from the server.
|
||||
|
||||
In clients which have their own commands the commands are typically prepended by a forward slash:`/`. Remote commands
|
||||
are always submitted to the server prepended with an exclamation point: `!`.
|
||||
|
||||
#### Local Commands
|
||||
|
||||
The following list is a list of client commands which may be available to you through your Archipelago client. You
|
||||
execute these commands in your client window.
|
||||
|
||||
The following commands are available in these clients: SNIClient, FactorioClient, FF1Client.
|
||||
|
||||
- `/connect <address:port>` Connect to the multiworld server.
|
||||
- `/disconnect` Disconnects you from your current session.
|
||||
- `/received` Displays all the items you have found or been sent.
|
||||
- `/missing` Displays all the locations along with their current status (checked/missing).
|
||||
- `/items` Lists all the item names for the current game.
|
||||
- `/locations` Lists all the location names for the current game.
|
||||
- `/ready` Sends ready status to the server.
|
||||
- `/help` Returns a list of available commands.
|
||||
- `/license` Returns the software licensing information.
|
||||
- Just typing anything will broadcast a message to all players
|
||||
|
||||
##### FF1Client Only
|
||||
|
||||
The following command is only available when using the FF1Client for the Final Fantasy Randomizer.
|
||||
|
||||
- `/nes` Shows the current status of the NES connection.
|
||||
|
||||
##### SNIClient Only
|
||||
|
||||
The following command is only available when using the SNIClient for SNES based games.
|
||||
|
||||
- `/snes` Attempts to connect to your SNES device via SNI.
|
||||
- `/snes_close` Closes the current SNES connection.
|
||||
- `/slow_mode` Toggles on or off slow mode, which limits the rate in which you receive items.
|
||||
|
||||
##### FactorioClient Only
|
||||
|
||||
The following command is only available when using the FactorioClient to play Factorio with Archipelago.
|
||||
|
||||
- `/factorio <command text>` Sends the command argument to the Factorio server as a command.
|
||||
|
||||
#### Remote Commands
|
||||
|
||||
Remote commands may be executed by any client which allows for sending text chat to the Archipelago server. If your
|
||||
client does not allow for sending chat then you may connect to your game slot with the TextClient which comes with the
|
||||
Archipelago installation. In order to execute the command you need to merely send a text message with the command,
|
||||
including the exclamation point.
|
||||
|
||||
- `!help` Returns a listing of available remote commands.
|
||||
- `!license` Returns the software licensing information.
|
||||
- `!countdown <countdown in seconds>` Starts a countdown using the given seconds value. Useful for synchronizing starts.
|
||||
Defaults to 10 seconds if no argument is provided.
|
||||
- `!options` Returns the current server options, including password in plaintext.
|
||||
- `!admin <command>` Executes a command as if you typed it into the server console. Remote administration must be
|
||||
enabled.
|
||||
- `!players` Returns info about the currently connected and non-connected players.
|
||||
- `!status` Returns information about your team. (Currently all players as teams are unimplemented.)
|
||||
- `!remaining` Lists the items remaining in your game, but not where they are or who they go to.
|
||||
- `!missing` Lists the location checks you are missing from the server's perspective.
|
||||
- `!checked` Lists all the location checks you've done from the server's perspective.
|
||||
- `!alias <alias>` Sets your alias.
|
||||
- `!getitem <item>` Cheats an item, if it is enabled in the server.
|
||||
- `!hint_location <location>` Hints for a location specifically. Useful in games where item names may match location
|
||||
names such as Factorio.
|
||||
- `!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 if you allowed forfeiting prior to goal completion. Remember that "
|
||||
forfeiting" actually means sending out your remaining items in your world.
|
||||
- `!collect` Grants you all the remaining checks in your world. Can only be used after your goal is complete or when you
|
||||
have forfeited.
|
||||
|
||||
#### Host only (on Archipelago.gg or in your server console)
|
||||
|
||||
- `/help` Returns a list of commands available in the console.
|
||||
- `/license` Returns the software licensing information.
|
||||
- `/countdown <seconds>` Starts a countdown which is sent to all players via text chat. Defaults to 10 seconds if no
|
||||
argument is provided.
|
||||
- `/options` Lists the server's current options, including password in plaintext.
|
||||
- `/save` Saves the state of the current multiworld. Note that the server autosaves on a minute basis.
|
||||
- `/players` List currently connected players.
|
||||
- `/exit` Shutdown the server
|
||||
- `/alias <player name> <alias name>` Assign a player an alias.
|
||||
- `/collect <player name>` Send out any items remaining in the multiworld belonging to the given player.
|
||||
- `/forfeit <player name>` Forfeits someone regardless of settings and game completion status
|
||||
- `/allow_forfeit <player name>` Allows the given player to use the `!forfeit` command.
|
||||
- `/forbid_forfeit <player name>` Bars the given player from using the `!forfeit` command.
|
||||
- `/send <player name> <item name>` Grants the given player the specified item.
|
||||
- `/send_multiple <amount> <player name> <item name>` Grants the given player the stated amount of the specified item.
|
||||
- `/hint <player name> <item or location name>` Send out a hint for the given item or location for the specified player.
|
||||
- `/option <option name> <option value>` Set a server option. For a list of options, use the `/options` command.
|
||||
215
WebHostLib/static/assets/tutorial/Archipelago/plando_en.md
Normal file
215
WebHostLib/static/assets/tutorial/Archipelago/plando_en.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# 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`.
|
||||
* You can add the necessary plando modules for your settings to the `requires` section of your yaml. Doing so will throw an error if the options that you need to generate properly are not enabled to ensure you will get the results you desire. Only enter in the plando modules that you are using here but it should look like:
|
||||
|
||||
```yaml
|
||||
requires:
|
||||
version: current.version.number
|
||||
plando: 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.
|
||||
|
||||
* The options for item plando are `from_pool`, `world`, `percentage`, `force`, `count`, 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, null, or a list. 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.
|
||||
* If a list of names is used, it will target the games with the player names specified.
|
||||
* `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. You can use true instead of a number to have it use however many of that item are in your item pool.
|
||||
* `locations` is a list of possible locations those items can be placed in.
|
||||
* Using the multi placement method, placements are picked randomly.
|
||||
* Instead of a number, you can use true
|
||||
* `count` can be used to set the maximum number of items placed from the block. The default is 1 if using `item` and False if using `items`
|
||||
* If a number is used it will try to place this number of items.
|
||||
* If set to false it will try to place as many items from the block as it can.
|
||||
* If `min` and `max` are defined, it will try to place a number of items between these two numbers at random
|
||||
|
||||
|
||||
### Available Items and Locations
|
||||
|
||||
A list of all available items and locations can be found in the [website's datapackage](/datapackage). The items and locations will be in the `"item_name_to_id"` and `"location_name_to_id"` sections of the relevant game. You do not need the quotes but the name must be entered in the same as it appears on that page and is caps-sensitive.
|
||||
|
||||
### 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
|
||||
|
||||
# example block 5 - Secret of Evermore
|
||||
- items:
|
||||
Levitate: 1
|
||||
Revealer: 1
|
||||
Energize: 1
|
||||
locations:
|
||||
- Master Sword Pedestal
|
||||
- Boss Relic 1
|
||||
world: true
|
||||
count: 2
|
||||
|
||||
# example block 6 - A Link to the Past
|
||||
- items:
|
||||
Progressive Sword: 4
|
||||
world:
|
||||
- BobsSlaytheSpire
|
||||
- BobsRogueLegacy
|
||||
count:
|
||||
min: 1
|
||||
max: 4
|
||||
```
|
||||
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 occurring and when it does will place all but 1 of the items randomly among the four
|
||||
locations chosen here.
|
||||
5. This block will always trigger and will attempt to place a random 2 of Levitate, Revealer and Energize into
|
||||
other players' Master Sword Pedestals or Boss Relic 1 locations.
|
||||
6. This block will always trigger and will attempt to place a random number, between 1 and 4, of progressive swords
|
||||
into any locations within the game slots named BobsSlaytheSpire and BobsRogueLegacy
|
||||
|
||||
|
||||
## 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.
|
||||
85
WebHostLib/static/assets/tutorial/Archipelago/setup_en.md
Normal file
85
WebHostLib/static/assets/tutorial/Archipelago/setup_en.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Archipelago Setup Guide
|
||||
|
||||
This guide is intended to provide an overview of how to install, set up, and run the Archipelago multiworld software.
|
||||
This guide should take about 5 minutes to read.
|
||||
|
||||
## Installing the Archipelago software
|
||||
|
||||
The most recent public release of Archipelago can be found on the GitHub Releases page. GitHub Releases
|
||||
page: [Archipelago Releases Page](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 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. Hosting on your machine requires forwarding the port
|
||||
you are hosting on. 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 are used 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 setup guide for that game. Installing a client for a ROM based game requires you to have a
|
||||
legally obtained ROM for that game as well.
|
||||
|
||||
## Generating a game
|
||||
|
||||
### What is a YAML?
|
||||
|
||||
YAML is the file format which Archipelago uses in order to configure a player's world. It allows you to dictate which
|
||||
game you will be playing as well as the settings you would like for that game.
|
||||
|
||||
YAML is a format very similar to JSON however it is made to be more human-readable. If you are ever unsure of the
|
||||
validity of your YAML file you may check the file by uploading it to the check page on the Archipelago website. Check
|
||||
page: [YAML Validation Page](/mysterycheck)
|
||||
|
||||
### Creating a YAML
|
||||
|
||||
YAML files may be generated on the Archipelago website by visiting the games page and clicking the "Settings Page" link
|
||||
under any game. Clicking "Export Settings" in a game's settings page will download the YAML to your system. Games
|
||||
page: [Archipelago Games List](/games)
|
||||
|
||||
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. If
|
||||
each player is planning on playing their own game then they will each need a YAML.
|
||||
|
||||
### 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.
|
||||
|
||||
Typically, a single participant of the multiworld will gather the YAML files from all other players. After getting the
|
||||
YAML files of each participant for your multiworld game they can be compressed into a ZIP folder to then be uploaded to
|
||||
the multiworld generator page. Multiworld generator
|
||||
page: [Archipelago Seed Generator Page](https://archipelago.gg/generate)
|
||||
|
||||
#### Rolling a YAML Locally
|
||||
|
||||
It is possible to roll the multiworld locally, using a local Archipelago installation. This is done by entering the
|
||||
installation directory of the Archipelago installation and placing each YAML file in the `Players` folder. If the folder
|
||||
does not exist then it can be created manually.
|
||||
|
||||
After filling the `Players` folder the `ArchipelagoGenerate.exe` program should be run in order to generate a
|
||||
multiworld. The output of this process is placed in the `output` folder.
|
||||
|
||||
#### 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 Archipelago
|
||||
installation folder. The settings chosen here are baked into the `.archipelago` 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.
|
||||
|
||||
## Hosting an Archipelago Server
|
||||
|
||||
The output of rolling a YAML will be a `.archipelago` file which can be subsequently uploaded to the Archipelago host
|
||||
game page. Archipelago host game page: [Archipelago Seed Upload Page](https://archipelago.gg/uploads)
|
||||
|
||||
The `.archipelago` file may be run locally in order to host the multiworld on the local machine. This is done by
|
||||
running `ArchipelagoServer.exe` and pointing the resulting file selection prompt to the `.archipelago` file that was
|
||||
generated.
|
||||
129
WebHostLib/static/assets/tutorial/Archipelago/triggers_en.md
Normal file
129
WebHostLib/static/assets/tutorial/Archipelago/triggers_en.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Archipelago Triggers Guide
|
||||
|
||||
This guide details the use of the Archipelago YAML trigger system. This guide is intended for a more advanced user with
|
||||
more in-depth knowledge of Archipelago YAML options as well as experience editing YAML files. This guide should take
|
||||
about 5 minutes to read.
|
||||
|
||||
## What are triggers?
|
||||
|
||||
Triggers allow you to customize your game settings by allowing you to define one or many options which only occur under
|
||||
specific 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 YAML that was created using entirely triggers and plando.
|
||||
|
||||
Mercenary mode
|
||||
YAML: [Mercenary Mode YAML on GitHub](https://github.com/alwaysintreble/Archipelago-yaml-dump/blob/main/Snippets/Mercenary%20Mode%20Snippet.yaml)
|
||||
|
||||
For more information on plando you can reference the general plando guide or the Link to the Past plando guide.
|
||||
|
||||
General plando guide: [Archipelago Plando Guide](/tutorial/archipelago/plando/en)
|
||||
|
||||
Link to the Past plando guide: [LttP Plando Guide](/tutorial/zelda3/plando/en)
|
||||
|
||||
## Trigger use
|
||||
|
||||
Triggers may be defined in either the root or in the relevant game sections. Generally, The best place to do this is the
|
||||
bottom of the yaml for clear organization.
|
||||
|
||||
- Triggers comprise 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 for 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.
|
||||
- `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. It is possible to have multiple things
|
||||
happen in this section.
|
||||
- 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.
|
||||
|
||||
It is also possible to use imaginary names in options to trigger specific settings. You can use these made up names in
|
||||
either your main options or to trigger from another trigger. Currently, this is the only way to trigger on "setting 1
|
||||
AND setting 2".
|
||||
|
||||
For example:
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- option_category: Secret of Evermore
|
||||
option_name: doggomizer
|
||||
option_result: pupdunk
|
||||
options:
|
||||
Secret of Evermore:
|
||||
difficulty:
|
||||
normal: 50
|
||||
pupdunk_hard: 25
|
||||
pupdunk_mystery: 25
|
||||
exp_modifier:
|
||||
150: 50
|
||||
200: 50
|
||||
- option_category: Secret of Evermore
|
||||
option_name: difficulty
|
||||
option_result: pupdunk_hard
|
||||
options:
|
||||
Secret of Evermore:
|
||||
fix_wings_glitch: false
|
||||
difficulty: hard
|
||||
- option_category: Secret of Evermore
|
||||
option_name: difficulty
|
||||
option_result: pupdunk_mystery
|
||||
options:
|
||||
Secret of Evermore:
|
||||
fix_wings_glitch: false
|
||||
difficulty: mystery
|
||||
```
|
||||
|
||||
In this example (thanks to @Black-Sliver) if the `pupdunk` option is rolled then the difficulty values will be rolled
|
||||
again using the new options `normal`, `pupdunk_hard`, and `pupdunk_mystery`, and the exp modifier will be rerolled using
|
||||
new weights for 150 and 200. This allows for two more triggers that will only be used for the new `pupdunk_hard`
|
||||
and `pupdunk_mystery` options so that they will only be triggered on "pupdunk AND hard/mystery".
|
||||
@@ -0,0 +1,33 @@
|
||||
# Using the Archipelago Website
|
||||
|
||||
This guide encompasses the use cases for rolling and hosting multiworld games on the Archipelago website. This guide
|
||||
should only take a couple of minutes to read.
|
||||
|
||||
## Rolling the Seed On the Website
|
||||
|
||||
1. After gathering the YAML files together in one location, select all the files and compress them into a `.ZIP` file.
|
||||
2. Next go to the "Generate Game" page. Generate game
|
||||
page: [Archipelago Seed Generation Page](https://archipelago.gg/generate). Here, you can adjust some server settings
|
||||
such as forfeit rules and the cost for a player to use a hint before generation.
|
||||
3. 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.
|
||||
4. 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.
|
||||
5. To begin playing, click on "Create New Room", which will take you to the room page. From here you can navigate back
|
||||
to the Seed Info page or to the Tracker page. Sharing the link to the room page with your friends will provide them
|
||||
with the necessary info and files for them to connect to the multiworld.
|
||||
|
||||
## Hosting a Pre-Generated Multiworld on 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 host game page. Host game
|
||||
page: [Archipelago Seed Upload Page](https://archipelago.gg/uploads)
|
||||
|
||||
This will give a page with the seed info and a link to the spoiler log, if it exists. Click on "Create New Room" and
|
||||
then share the link to the resulting page 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.
|
||||
@@ -0,0 +1,45 @@
|
||||
# ChecksFinder Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- ChecksFinder from
|
||||
the [Github releases Page for the game](https://github.com/jonloveslegos/ChecksFinder/releases) (latest version)
|
||||
- Archipelago from the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- (select `ChecksFinder Client` during installation.)
|
||||
|
||||
## Configuring your YAML file
|
||||
|
||||
### What is a YAML file and why do I need one?
|
||||
|
||||
See the guide on setting up a basic YAML at the Archipelago setup
|
||||
guide: [Basic Multiworld Setup Guide](/tutorial/archipelago/setup/en)
|
||||
|
||||
### Where do I get a YAML file?
|
||||
|
||||
You can customize your settings by visiting the [ChecksFinder Player Settings Page](/games/ChecksFinder/player-settings)
|
||||
|
||||
### Generating a ChecksFinder game
|
||||
|
||||
**ChecksFinder is meant to be played _alongside_ another game! You may not be playing it for long periods of time if
|
||||
you play it by itself with another person!**
|
||||
|
||||
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. You do not have a file inside that zip though!
|
||||
|
||||
You need to start ChecksFinder client yourself, it is located within the Archipelago folder.
|
||||
|
||||
### Connect to the MultiServer
|
||||
|
||||
First start ChecksFinder.
|
||||
|
||||
Once both ChecksFinder and the client are started. In the client at the top type in the spot labeled `Server` type the
|
||||
`Ip Address` and `Port` separated with a `:` symbol.
|
||||
|
||||
The client will then ask for the username you chose, input that in the text box at the bottom of the client.
|
||||
|
||||
### 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!
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
WebHostLib/static/assets/tutorial/Factorio/factorio-download.png
Normal file
BIN
WebHostLib/static/assets/tutorial/Factorio/factorio-download.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 627 KiB |
153
WebHostLib/static/assets/tutorial/Factorio/setup_en.md
Normal file
153
WebHostLib/static/assets/tutorial/Factorio/setup_en.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Factorio Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
##### Players
|
||||
|
||||
- Factorio: [Factorio Official Website](https://factorio.com)
|
||||
- Needed by Players and Hosts
|
||||
|
||||
##### Server Hosts
|
||||
|
||||
- Factorio: [Factorio Official Website](https://factorio.com)
|
||||
- Needed by Players and Hosts
|
||||
- Archipelago: [Archipelago Releases Page](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 page on the website allows you to configure your personal settings and export a config file from
|
||||
them. Factorio player settings page: [Factorio Settings Page](/games/Factorio/player-settings)
|
||||
|
||||
### 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 page. YAML
|
||||
Validator page: [Yaml Validation Page](/mysterycheck)
|
||||
|
||||
## 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\<YourUserName>\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: [Factorio Official Website Download Page](https://factorio.com/download).
|
||||
|
||||
#### If you purchased Factorio on Steam, GOG, etc.
|
||||
|
||||
You can register your copy of Factorio on factorio.com: [Factorio Official Website](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).
|
||||
|
||||

|
||||
|
||||
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"
|
||||
```
|
||||
|
||||
This allows you to host your own Factorio game.
|
||||
|
||||
## Hosting 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"
|
||||
|
||||

|
||||
|
||||
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 whatismyip.com: [WhatIsMyIP 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: [Factorio (Steam) Archipelago Setup Guide for Windows](https://docs.google.com/document/d/1yZPAaXB-QcetD8FJsmsFrenAHO5V6Y2ctMAyIoT9jS4)
|
||||
- Factorio Speedrun Guide: [Factorio Speedrun Guide by Nefrums](https://www.youtube.com/watch?v=ExLrmK1c7tA)
|
||||
- Factorio Wiki: [Factorio Official Wiki](https://wiki.factorio.com/)
|
||||
@@ -0,0 +1,74 @@
|
||||
# Final Fantasy 1 (NES) Multiworld Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- The FF1Client
|
||||
- Bundled with Archipelago: [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- The BizHawk emulator. Versions 2.3.1 and higher are supported. Version 2.7 is recommended
|
||||
- [BizHawk Official Website](http://tasvideos.org/BizHawk.html)
|
||||
- Your legally obtained 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.
|
||||
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**.
|
||||
|
||||
## Obtaining your Archipelago yaml file and randomized ROM
|
||||
|
||||
Unlike most other Archipelago.gg games Final Fantasy 1 is randomized by the main randomizer at
|
||||
the [Final Fantasy Randomizer Homepage](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
|
||||
|
||||
At this point you are ready to join the multiworld. If you are uncertain on how to generate, host or join a multiworld
|
||||
please refer to the [game agnostic setup guide](/tutorial/archipelago/setup/en).
|
||||
|
||||
## Running the Client Program and Connecting to the Server
|
||||
|
||||
Once the Archipelago server has been hosted:
|
||||
|
||||
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`
|
||||
|
||||
### Other Client Commands
|
||||
|
||||
All other commands may be found on the [Archipelago Server and Client Commands Guide](/tutorial/archipelago/commands/en)
|
||||
.
|
||||
30
WebHostLib/static/assets/tutorial/Hollow Knight/setup_en.md
Normal file
30
WebHostLib/static/assets/tutorial/Hollow Knight/setup_en.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Hollow Knight for Archipelago Setup Guide
|
||||
|
||||
## Required Software
|
||||
* Download and unzip the Scarab Mod Manager from the [Scarab GitHub Releases page](https://github.com/fifty-six/Scarab/releases).
|
||||
* A legal copy of Hollow Knight, not purchased or played through XBox Game Pass.
|
||||
* Unfortunately, the Game Pass version is not currently compatible with mods.
|
||||
|
||||
## Installing the Archipelago Mod using Scarab
|
||||
1. Launch Scarab and ensure it locates your Hollow Knight installation directory.
|
||||
2. Click the "Install" checkbox near the "Archipelago" mod entry.
|
||||
3. Launch the game, you're all set!
|
||||
|
||||
## Configuring your YAML File
|
||||
### What is a YAML and why do I need one?
|
||||
You can see the [basic multiworld setup guide](/tutorial/Archipelago/setup/en) here on the Archipelago website to learn
|
||||
about why Archipelago uses YAML files and what they're for.
|
||||
|
||||
### Where do I get a YAML?
|
||||
You can use the [game settings page for Hollow Knight](/games/Hollow%20Knight/player-settings) here on the Archipelago
|
||||
website to generate a YAML using a graphical interface.
|
||||
|
||||
### Joining an Archipelago Game in Hollow Knight
|
||||
1. Start the game after installing all necessary mods.
|
||||
2. Create a **new save game.**
|
||||
3. Select the **Archipelago** game mode from the mode selection screen.
|
||||
4. Enter the correct settings for your Archipelago server.
|
||||
5. Hit **Start** to begin the game. The game will stall for a few seconds while it does all item placements.
|
||||
6. The game will immediately drop you into the randomized game.
|
||||
* If you are waiting for a countdown then wait for it to lapse before hitting Start.
|
||||
* Or hit Start then pause the game once you're in it.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user