mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-07 15:13:52 -08:00
Compare commits
2912 Commits
0.1.0
...
tests_apwo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1db6b67953 | ||
|
|
fa3c132304 | ||
|
|
6226713c4d | ||
|
|
b56da79890 | ||
|
|
1d6345d3a2 | ||
|
|
51a639ceaf | ||
|
|
7ecb1e6d6c | ||
|
|
c9fb443c64 | ||
|
|
325299286b | ||
|
|
776b5fab7c | ||
|
|
18e0d25051 | ||
|
|
dfb3df4a8f | ||
|
|
d0db728850 | ||
|
|
77b0852dca | ||
|
|
3fba94f000 | ||
|
|
85582b9458 | ||
|
|
122d404145 | ||
|
|
07e3fbe845 | ||
|
|
76cace725b | ||
|
|
99656bf059 | ||
|
|
332eab9569 | ||
|
|
8c2584f872 | ||
|
|
1ced726d31 | ||
|
|
d51e0ec0ab | ||
|
|
36b5b1207c | ||
|
|
a4e485e297 | ||
|
|
a7bc8846cd | ||
|
|
125ee8b198 | ||
|
|
553fe0be19 | ||
|
|
71bfb6babd | ||
|
|
1698c17caa | ||
|
|
751e5cec63 | ||
|
|
dc46e96e3f | ||
|
|
0934e5c711 | ||
|
|
aa8ffa247d | ||
|
|
a45e8730cb | ||
|
|
46f2f3d7cd | ||
|
|
a96ff8de16 | ||
|
|
f3e2e429b8 | ||
|
|
46b13e0b53 | ||
|
|
7a4e903906 | ||
|
|
f1ccf1b663 | ||
|
|
ec0822c5eb | ||
|
|
78b981228a | ||
|
|
f3c788d0cc | ||
|
|
59ad9e97e5 | ||
|
|
abd8eaf36e | ||
|
|
f36468fc25 | ||
|
|
a939f50480 | ||
|
|
b04b105bd8 | ||
|
|
845502ad39 | ||
|
|
afe9e12ef4 | ||
|
|
a75159b57e | ||
|
|
61fc80505e | ||
|
|
25f285b242 | ||
|
|
c4e28a8736 | ||
|
|
422ccdaa4c | ||
|
|
1e7c650159 | ||
|
|
ab64173600 | ||
|
|
36499b8983 | ||
|
|
923ff033b1 | ||
|
|
599d0ac81b | ||
|
|
ce2433b247 | ||
|
|
f6cb90daf9 | ||
|
|
54b200451d | ||
|
|
b98080afee | ||
|
|
5401e485aa | ||
|
|
58cf9783eb | ||
|
|
fad0fe16f4 | ||
|
|
c2884e9eb0 | ||
|
|
1809823308 | ||
|
|
df7462efcc | ||
|
|
00e3c44400 | ||
|
|
abf4b3bcbc | ||
|
|
c9f217943e | ||
|
|
e9f8b1ed28 | ||
|
|
c46d8afcfa | ||
|
|
f4d9c294a3 | ||
|
|
42d8fb8409 | ||
|
|
127d4812b5 | ||
|
|
527f30d91a | ||
|
|
1d565b9aaf | ||
|
|
6814bc158a | ||
|
|
e80f3206b6 | ||
|
|
54ea917c48 | ||
|
|
5e9bf4b007 | ||
|
|
c8453035da | ||
|
|
a2ddd5c9e8 | ||
|
|
97ba631b80 | ||
|
|
be4c597c8d | ||
|
|
324d3cf042 | ||
|
|
b1c5456d18 | ||
|
|
f474b81f40 | ||
|
|
5255bc5cd8 | ||
|
|
18127a75f5 | ||
|
|
899de428df | ||
|
|
f401702e7c | ||
|
|
68bfe1705d | ||
|
|
98b0bf7456 | ||
|
|
7674e62ba7 | ||
|
|
0b33c25b39 | ||
|
|
62f4e62d71 | ||
|
|
48add4687c | ||
|
|
cc08e853a0 | ||
|
|
7e3fa5058d | ||
|
|
c74577d708 | ||
|
|
a8b76b1310 | ||
|
|
c8ebad1dfe | ||
|
|
d3447a3983 | ||
|
|
11b2b5ed2f | ||
|
|
a0464ecea1 | ||
|
|
97fd78ba1b | ||
|
|
a60f370224 | ||
|
|
0363630f61 | ||
|
|
39c7c7291e | ||
|
|
a368520200 | ||
|
|
5d25f908a4 | ||
|
|
9edab76567 | ||
|
|
91b60f2e21 | ||
|
|
41b59488e3 | ||
|
|
42da24cb5e | ||
|
|
28c5e9ee65 | ||
|
|
b55174ccdf | ||
|
|
7bcf299412 | ||
|
|
a7816d186f | ||
|
|
9d40471dee | ||
|
|
b704070de5 | ||
|
|
6c459066a7 | ||
|
|
4c3eaf2996 | ||
|
|
bb56f7b400 | ||
|
|
22ed7ff9c3 | ||
|
|
173513c9f4 | ||
|
|
c0cf35edda | ||
|
|
dcc628f878 | ||
|
|
b950af09a6 | ||
|
|
58aea7ca58 | ||
|
|
06a25a903e | ||
|
|
62a265cc31 | ||
|
|
67c3076572 | ||
|
|
ab5cb7adad | ||
|
|
5e84f91d2f | ||
|
|
a7a17a5a4d | ||
|
|
a6ea3e1953 | ||
|
|
0515acc8fe | ||
|
|
bf5c1cbbbf | ||
|
|
c8fb46a5e6 | ||
|
|
a38a2903d5 | ||
|
|
4ef7e43521 | ||
|
|
e1f17fadfc | ||
|
|
4dc934729d | ||
|
|
722757e18a | ||
|
|
0ca3c5e6a2 | ||
|
|
664bbd86bb | ||
|
|
7a9d4272be | ||
|
|
7559adbb14 | ||
|
|
1a7bc4ffd4 | ||
|
|
be74a4a71a | ||
|
|
cb634fa8d4 | ||
|
|
f6758524d5 | ||
|
|
f395a6d184 | ||
|
|
ea03c90152 | ||
|
|
50d9ab041a | ||
|
|
acd3cb45bf | ||
|
|
8a78062825 | ||
|
|
599cd2c82e | ||
|
|
89ec31708e | ||
|
|
ef211da27f | ||
|
|
0122eb38ab | ||
|
|
3d8bc0bb67 | ||
|
|
d85c13ef0e | ||
|
|
27cb93d319 | ||
|
|
b0e8c8db6b | ||
|
|
5a7d20d393 | ||
|
|
808203a50f | ||
|
|
d8f79b4a42 | ||
|
|
02ef6cee47 | ||
|
|
e716b50f8c | ||
|
|
3fdf07677c | ||
|
|
d3baca9251 | ||
|
|
f52ca2571f | ||
|
|
469807ba01 | ||
|
|
8ada91939c | ||
|
|
054d14baa4 | ||
|
|
f0324e60f8 | ||
|
|
70ff19ac8c | ||
|
|
b02b329181 | ||
|
|
8b7ffaf671 | ||
|
|
c711d803f8 | ||
|
|
3c3954f5e8 | ||
|
|
05d398a51d | ||
|
|
5eadbc9840 | ||
|
|
0c1e3097c3 | ||
|
|
cdf7ca1dcc | ||
|
|
77fbd0eb2b | ||
|
|
c7284f90d9 | ||
|
|
8d559daa35 | ||
|
|
e49ffc64f2 | ||
|
|
94a02510c0 | ||
|
|
9d73988030 | ||
|
|
81411a191c | ||
|
|
6059b5ef66 | ||
|
|
0bc5a3bc8d | ||
|
|
11fdb29357 | ||
|
|
bbef7a4cbc | ||
|
|
8e7bbb4ea8 | ||
|
|
6628e8c85d | ||
|
|
84402a1b55 | ||
|
|
f4035b8621 | ||
|
|
bbf8546867 | ||
|
|
67a22b8b43 | ||
|
|
8e6ec85532 | ||
|
|
8fc50510a0 | ||
|
|
aa6ad5d34f | ||
|
|
ccb89dd65c | ||
|
|
a86c0aa37d | ||
|
|
ece6598b09 | ||
|
|
eef8f7af1a | ||
|
|
c626618221 | ||
|
|
47989325f8 | ||
|
|
815e7e6b0a | ||
|
|
a61a1f58c6 | ||
|
|
4c24872264 | ||
|
|
e778e49574 | ||
|
|
25f7413881 | ||
|
|
397ce8343e | ||
|
|
37fdc00517 | ||
|
|
03aa9b3604 | ||
|
|
8f52e4654f | ||
|
|
a86fd37860 | ||
|
|
eb503adb13 | ||
|
|
cbf72becc1 | ||
|
|
cdd460ae15 | ||
|
|
ffd968d89d | ||
|
|
8d73746d5b | ||
|
|
5ed56db48a | ||
|
|
5f447f4e6b | ||
|
|
f015cf4298 | ||
|
|
e43bb99622 | ||
|
|
34de5a57af | ||
|
|
510a460d84 | ||
|
|
6e271b643d | ||
|
|
8971340a66 | ||
|
|
30cfd3186c | ||
|
|
1dc4e2b44b | ||
|
|
d5b4a91a13 | ||
|
|
bf5282dfa8 | ||
|
|
4eea91daab | ||
|
|
20e80d06cf | ||
|
|
59b78528a9 | ||
|
|
cd4fd18706 | ||
|
|
af44c1ba3d | ||
|
|
3ef0a56ec2 | ||
|
|
4ff282a384 | ||
|
|
f3dad894ec | ||
|
|
a5373e3672 | ||
|
|
639606e0be | ||
|
|
bb79073ce7 | ||
|
|
53b3cd029e | ||
|
|
99bd525c8e | ||
|
|
d14ab97849 | ||
|
|
f50e85b401 | ||
|
|
b64565594a | ||
|
|
ae7dad8bf9 | ||
|
|
b7c74919b7 | ||
|
|
a7f7f91aaf | ||
|
|
e62f989ce8 | ||
|
|
21c6c28755 | ||
|
|
f0403b9c9d | ||
|
|
f09f3663d6 | ||
|
|
b5bd93c420 | ||
|
|
90813c0f4b | ||
|
|
e2c4293a6d | ||
|
|
963c33c02a | ||
|
|
7d603e7d8d | ||
|
|
f2e1495d39 | ||
|
|
7927b2ee25 | ||
|
|
4f2b13a674 | ||
|
|
ffd7d5da74 | ||
|
|
67eb370200 | ||
|
|
4456e36fbb | ||
|
|
7fd9e71b3c | ||
|
|
f4a68f1c3d | ||
|
|
754a57cf69 | ||
|
|
384577e421 | ||
|
|
0ed3865c30 | ||
|
|
77b2ed54a6 | ||
|
|
0386d9f6d2 | ||
|
|
7e52b6d8bb | ||
|
|
03cf525b2c | ||
|
|
e1f46d623c | ||
|
|
5bb6ff0ce0 | ||
|
|
256f493ada | ||
|
|
3ec2d45f4f | ||
|
|
b3895750ab | ||
|
|
7591404151 | ||
|
|
d48e1e447f | ||
|
|
206f8cf5ed | ||
|
|
0c6b1827fe | ||
|
|
017f91c1b5 | ||
|
|
95b01def6b | ||
|
|
5977e401d5 | ||
|
|
21a3c74783 | ||
|
|
2fb9176511 | ||
|
|
1c69fb3c3c | ||
|
|
91502505a1 | ||
|
|
01c13ca243 | ||
|
|
c2a8b842de | ||
|
|
856efebc39 | ||
|
|
5a4203649d | ||
|
|
ddb764a9b6 | ||
|
|
9f65f22fac | ||
|
|
b7ff9b69ba | ||
|
|
012e6ba24c | ||
|
|
cd9d0bebc8 | ||
|
|
3fa6588637 | ||
|
|
e6d16c905c | ||
|
|
958829d491 | ||
|
|
9ee37b0ec5 | ||
|
|
81a239325d | ||
|
|
67bf12369a | ||
|
|
d4b793902f | ||
|
|
6671b21a86 | ||
|
|
6d13dc4944 | ||
|
|
ff9f563d4a | ||
|
|
d825576f12 | ||
|
|
5d6184f1fd | ||
|
|
e433246f0c | ||
|
|
3a190a8fb2 | ||
|
|
4b7033fce7 | ||
|
|
37499b40a1 | ||
|
|
ca2c0e6ce2 | ||
|
|
2a28a6de28 | ||
|
|
573a1a8402 | ||
|
|
060ee926e7 | ||
|
|
df55455fc0 | ||
|
|
4d7bd929bc | ||
|
|
030e41363a | ||
|
|
4bc0e84a7f | ||
|
|
070a92e76c | ||
|
|
39563cc347 | ||
|
|
54cce4c392 | ||
|
|
426a81a065 | ||
|
|
04e6a8eae8 | ||
|
|
0cfdc973f6 | ||
|
|
f3ca0a21c9 | ||
|
|
5fef41eb97 | ||
|
|
4068ba2f15 | ||
|
|
b1599c557f | ||
|
|
7fdf38b2ad | ||
|
|
2e76085cf1 | ||
|
|
c61f467218 | ||
|
|
942d689093 | ||
|
|
5e1aa52373 | ||
|
|
a95e51deda | ||
|
|
738319462d | ||
|
|
e3deb822ad | ||
|
|
d57314a407 | ||
|
|
5a8e6e61f5 | ||
|
|
17e90ce12c | ||
|
|
016157a0eb | ||
|
|
5b64c5f934 | ||
|
|
414166f6a2 | ||
|
|
e6109394ad | ||
|
|
8ca25fed63 | ||
|
|
227d59ecfb | ||
|
|
08c17c83d4 | ||
|
|
efb2ab4505 | ||
|
|
3a68ce3faa | ||
|
|
e78800d1bc | ||
|
|
96d7a3a64c | ||
|
|
30b70b2055 | ||
|
|
cd234fc04a | ||
|
|
d74c4c4c94 | ||
|
|
a4b61118cf | ||
|
|
9fa1f4e85f | ||
|
|
3a926849a0 | ||
|
|
798d823397 | ||
|
|
4ea582f14e | ||
|
|
21fb16291d | ||
|
|
805f33c39e | ||
|
|
0cf8206660 | ||
|
|
2c20b56478 | ||
|
|
1d2f7d8669 | ||
|
|
0733775f2c | ||
|
|
d6f3b27695 | ||
|
|
ce7e6bcf33 | ||
|
|
2c4658a7e0 | ||
|
|
79b8733b13 | ||
|
|
9cb9cbe47d | ||
|
|
7cad53c31a | ||
|
|
f3bdf0c5ed | ||
|
|
af7d0dbf37 | ||
|
|
0286edf20c | ||
|
|
05e36cab1c | ||
|
|
50425985c4 | ||
|
|
062d6eeace | ||
|
|
6c460bcbf7 | ||
|
|
b8659d28cc | ||
|
|
0b12d80008 | ||
|
|
5966aa5327 | ||
|
|
7c68e91d4a | ||
|
|
1d6ab13015 | ||
|
|
cb3d40624c | ||
|
|
0eb66957b1 | ||
|
|
53e2232f29 | ||
|
|
ecd2675ea8 | ||
|
|
fc2e555b4a | ||
|
|
df020bb389 | ||
|
|
7760034ff7 | ||
|
|
3e7794d5dc | ||
|
|
a3e8bb474a | ||
|
|
e4c95c940a | ||
|
|
daa1809a0f | ||
|
|
0a1261eb84 | ||
|
|
b62be6f7f4 | ||
|
|
ce2553a2b3 | ||
|
|
18c4b4b1fe | ||
|
|
a85ca9cc87 | ||
|
|
ad4846cedd | ||
|
|
b20be3ccec | ||
|
|
8af7908cd0 | ||
|
|
f078750b72 | ||
|
|
7cbeb8438b | ||
|
|
f7a0542898 | ||
|
|
cc61f16e57 | ||
|
|
9e3c2e2464 | ||
|
|
f528175d8a | ||
|
|
803d7105a1 | ||
|
|
a40f6058b5 | ||
|
|
0ff3c693d5 | ||
|
|
873a374a69 | ||
|
|
60584b7617 | ||
|
|
e24a85ca5c | ||
|
|
cc0540d3fb | ||
|
|
c360b9266c | ||
|
|
6148213e43 | ||
|
|
ff175008a1 | ||
|
|
cae1e683e2 | ||
|
|
fb1a9e9c5a | ||
|
|
555a0da46d | ||
|
|
0817305d5b | ||
|
|
995c978628 | ||
|
|
4de7ebd8b0 | ||
|
|
3cef39a387 | ||
|
|
ffff9ece55 | ||
|
|
dc2aa5f41e | ||
|
|
428344b6bc | ||
|
|
ea2175cb8a | ||
|
|
11873e059a | ||
|
|
6c1023a88c | ||
|
|
0be0732a2b | ||
|
|
c9aa283711 | ||
|
|
cf2204a861 | ||
|
|
dfdcad28e5 | ||
|
|
ab4324c901 | ||
|
|
1e251dcdc0 | ||
|
|
9c1f7bfea9 | ||
|
|
5393563700 | ||
|
|
28576f2b0d | ||
|
|
ba519fecd0 | ||
|
|
86fb450ecc | ||
|
|
920240cb6f | ||
|
|
53dd0d5a7d | ||
|
|
807f544b26 | ||
|
|
1d1693df62 | ||
|
|
51574959ec | ||
|
|
04f726aef2 | ||
|
|
8a4298e504 | ||
|
|
e7f8f40464 | ||
|
|
847582ff5f | ||
|
|
1a44f5cf1c | ||
|
|
032bc75070 | ||
|
|
fb47483212 | ||
|
|
d185df3972 | ||
|
|
941dcb60e5 | ||
|
|
25756831b7 | ||
|
|
9add1495d5 | ||
|
|
34dba007dc | ||
|
|
02d3eef565 | ||
|
|
c839a76fe7 | ||
|
|
29e1c3dcf4 | ||
|
|
f6616da5a9 | ||
|
|
8678e02d54 | ||
|
|
2f37bedc92 | ||
|
|
91fdfe3e17 | ||
|
|
a41b0051a6 | ||
|
|
b8abe9f980 | ||
|
|
dd3ae5ecbd | ||
|
|
e96602d31b | ||
|
|
81d953daa3 | ||
|
|
bd774a454e | ||
|
|
ca724c92ad | ||
|
|
11eebbbd32 | ||
|
|
608794cded | ||
|
|
816de5ff02 | ||
|
|
0b941e2268 | ||
|
|
57713cda50 | ||
|
|
f56cdd6ec3 | ||
|
|
773c517757 | ||
|
|
2509b7fa3f | ||
|
|
10652d23e0 | ||
|
|
f0bc3d33ac | ||
|
|
92d1ed60c6 | ||
|
|
fe2b431821 | ||
|
|
0cc83698f9 | ||
|
|
428f643b07 | ||
|
|
d4e2b75520 | ||
|
|
96cc7f79dc | ||
|
|
bdfbc7e14a | ||
|
|
94c6562f82 | ||
|
|
22fe31a141 | ||
|
|
72fa19ee1f | ||
|
|
d899e918b4 | ||
|
|
33d31c4f0f | ||
|
|
9c3c69702a | ||
|
|
dae1a3e0f9 | ||
|
|
1f1ef10cfe | ||
|
|
760af59308 | ||
|
|
3dd7e3e706 | ||
|
|
189b129dca | ||
|
|
092e8d14ad | ||
|
|
4cfc73b582 | ||
|
|
ff9c11d772 | ||
|
|
b83aec5c12 | ||
|
|
caf63dd737 | ||
|
|
395d35571c | ||
|
|
e0be79639c | ||
|
|
37b7f0d32d | ||
|
|
50677ee6a2 | ||
|
|
f8bc3359c7 | ||
|
|
6e537e17e6 | ||
|
|
e853fc208b | ||
|
|
1a36da33b4 | ||
|
|
56fc614588 | ||
|
|
47f1fcf382 | ||
|
|
51c6be047f | ||
|
|
2c46c48ba9 | ||
|
|
32820ba653 | ||
|
|
6173bc6e03 | ||
|
|
e71ea94fe5 | ||
|
|
e3f169b4c3 | ||
|
|
e4e74074f0 | ||
|
|
149630d532 | ||
|
|
2dcfbff751 | ||
|
|
ec45479c52 | ||
|
|
aee0df5359 | ||
|
|
2cdd03f786 | ||
|
|
ce42fda85f | ||
|
|
78a18dee4e | ||
|
|
b7d46004e2 | ||
|
|
c3fe341736 | ||
|
|
79bb43b77c | ||
|
|
bedc78d335 | ||
|
|
1b582e5b09 | ||
|
|
f278dd95c5 | ||
|
|
92f75f3e03 | ||
|
|
f5adc7bdc5 | ||
|
|
78d4da53a7 | ||
|
|
e206c065bf | ||
|
|
5273812039 | ||
|
|
7c3af68e59 | ||
|
|
449973687b | ||
|
|
f5638552cc | ||
|
|
78ee19de51 | ||
|
|
82444229be | ||
|
|
2cc03d003a | ||
|
|
0e4fa378dd | ||
|
|
ffc000ec91 | ||
|
|
32b8f9f9f3 | ||
|
|
4412434976 | ||
|
|
9bdbced51f | ||
|
|
bd574ef261 | ||
|
|
45719eb7e0 | ||
|
|
d81fd280fa | ||
|
|
6b57275859 | ||
|
|
63f012cce7 | ||
|
|
679cb3e197 | ||
|
|
38b5a90c07 | ||
|
|
203f17f0f6 | ||
|
|
65995cd586 | ||
|
|
64e2d55e92 | ||
|
|
ef66f64030 | ||
|
|
e641c3ca1b | ||
|
|
111c3186bd | ||
|
|
f0e9080108 | ||
|
|
fd8867c782 | ||
|
|
f81d2653e0 | ||
|
|
1288f15e45 | ||
|
|
cde2a6e754 | ||
|
|
81dd1e359b | ||
|
|
8dffd87bee | ||
|
|
67be80e59d | ||
|
|
ff1f5569e7 | ||
|
|
8b9b482972 | ||
|
|
d0ce44cd38 | ||
|
|
aae78a8a12 | ||
|
|
7a5e11e8d4 | ||
|
|
a9ab53cb8b | ||
|
|
5ed8c2e1c0 | ||
|
|
67128ece38 | ||
|
|
8aed24151f | ||
|
|
3e6c097348 | ||
|
|
8ce3fd5518 | ||
|
|
93a354cd81 | ||
|
|
774581b7ba | ||
|
|
95f90851ac | ||
|
|
1cd1bfea4d | ||
|
|
edd1fff4b7 | ||
|
|
4d79920fa6 | ||
|
|
7665935227 | ||
|
|
5139475068 | ||
|
|
adcee639a2 | ||
|
|
fde97fca5b | ||
|
|
e108b67ca5 | ||
|
|
17da06f763 | ||
|
|
2ff737175f | ||
|
|
b0b8268249 | ||
|
|
4e5c10ad66 | ||
|
|
350e1e6287 | ||
|
|
63c0d027e7 | ||
|
|
a014bb4ab7 | ||
|
|
0d10fec395 | ||
|
|
0cbee4ac3e | ||
|
|
70cab99caf | ||
|
|
c1e97bcbff | ||
|
|
e2eaafbf70 | ||
|
|
66d594e95b | ||
|
|
a9bf0008ba | ||
|
|
f2426ae603 | ||
|
|
462ddce72c | ||
|
|
d10bb3c6c1 | ||
|
|
61232ca756 | ||
|
|
8f325a4f2b | ||
|
|
d28738a918 | ||
|
|
1f3d048462 | ||
|
|
b161a5241f | ||
|
|
208a0c6b08 | ||
|
|
c3c1ce5827 | ||
|
|
889bc9d1b4 | ||
|
|
165a38dd58 | ||
|
|
88088dd054 | ||
|
|
c933fa7e34 | ||
|
|
f1123f2662 | ||
|
|
0f034ddcf7 | ||
|
|
7f3eda4623 | ||
|
|
2b0e7f05da | ||
|
|
e204deab02 | ||
|
|
56afd62175 | ||
|
|
44204ac9be | ||
|
|
6c3852a2a9 | ||
|
|
124ae198e4 | ||
|
|
030b767751 | ||
|
|
5ca724a454 | ||
|
|
af3b752093 | ||
|
|
c378933274 | ||
|
|
da392239a0 | ||
|
|
a6e1e14fee | ||
|
|
95378233fc | ||
|
|
85130f2bbd | ||
|
|
ab9f3767e2 | ||
|
|
bf142b32c9 | ||
|
|
05c06a57af | ||
|
|
0f7adaaf7b | ||
|
|
962e48c078 | ||
|
|
95ea0541e6 | ||
|
|
0ed3baabd4 | ||
|
|
2db55ac50b | ||
|
|
bea8d37a3c | ||
|
|
813015e007 | ||
|
|
c1d7abd06e | ||
|
|
655f287d42 | ||
|
|
802119502d | ||
|
|
2af510328e | ||
|
|
87f4a97f1e | ||
|
|
1bb99d391d | ||
|
|
1cad51b1af | ||
|
|
09d8c4b912 | ||
|
|
ed23a426ec | ||
|
|
c711264d1a | ||
|
|
3dfbbc5057 | ||
|
|
f298b8d6e7 | ||
|
|
53974d568b | ||
|
|
ec0389eefb | ||
|
|
0c54c47023 | ||
|
|
80db8a33af | ||
|
|
e6c6b00109 | ||
|
|
813ea6ef8b | ||
|
|
c09e089f9d | ||
|
|
cfff12d8d7 | ||
|
|
924f484be0 | ||
|
|
aeb78eaa10 | ||
|
|
6134578c60 | ||
|
|
b57ca33c31 | ||
|
|
4b18920819 | ||
|
|
700fe8b75e | ||
|
|
d5efc71344 | ||
|
|
6535836e5c | ||
|
|
89d1a80e01 | ||
|
|
ad445629bd | ||
|
|
37c5865c0e | ||
|
|
52726139b4 | ||
|
|
24105ac249 | ||
|
|
f18df4c1df | ||
|
|
04b6c31076 | ||
|
|
40c3ef35c7 | ||
|
|
28483a6c14 | ||
|
|
fa077defe0 | ||
|
|
47b4e2782b | ||
|
|
265ee7098a | ||
|
|
ed76c13961 | ||
|
|
40b7e78178 | ||
|
|
1900d9382a | ||
|
|
f12b73f487 | ||
|
|
49ae79e5ce | ||
|
|
4da6a0bb98 | ||
|
|
af0cfc5a38 | ||
|
|
1aa3e431c8 | ||
|
|
b533ffb9e8 | ||
|
|
bb46ee7fc1 | ||
|
|
acf7fda26a | ||
|
|
f7fc6fa7aa | ||
|
|
5e97463bdc | ||
|
|
ca9c3d05d6 | ||
|
|
51f65f4b9e | ||
|
|
1f01404ca4 | ||
|
|
bbb6ee89cf | ||
|
|
0aea1e780f | ||
|
|
722b3c5369 | ||
|
|
097ac189e4 | ||
|
|
7f3f886e41 | ||
|
|
3bd4ef3f3d | ||
|
|
6b9073acd7 | ||
|
|
e708bea819 | ||
|
|
b014ce082b | ||
|
|
30a4bcbbbe | ||
|
|
0afb7096de | ||
|
|
f909576813 | ||
|
|
9f684b3dc0 | ||
|
|
37a40499fa | ||
|
|
099c4fca3c | ||
|
|
106d630ad7 | ||
|
|
4c0c93b083 | ||
|
|
3cbbf905d1 | ||
|
|
414ebf2640 | ||
|
|
3297be7902 | ||
|
|
7b3ef012b9 | ||
|
|
af6a72c3c3 | ||
|
|
38b7bdfe60 | ||
|
|
4c266e6eff | ||
|
|
8a6c9ff4b8 | ||
|
|
fdd7ffb089 | ||
|
|
b8e467fbb8 | ||
|
|
411cd51a92 | ||
|
|
e9e15e854d | ||
|
|
4943d26160 | ||
|
|
060a04700d | ||
|
|
61e39f355d | ||
|
|
8ab0b410c3 | ||
|
|
d897aaade2 | ||
|
|
0191df88d7 | ||
|
|
bee1fd9b5a | ||
|
|
dd7d3a02a4 | ||
|
|
13edfa60be | ||
|
|
885c8d3fcc | ||
|
|
e6a4925f0c | ||
|
|
c96b6d7b95 | ||
|
|
8bc8b412a3 | ||
|
|
b4b9ff5d82 | ||
|
|
b21b5cceb8 | ||
|
|
813ee5ee3b | ||
|
|
be1158ad78 | ||
|
|
6d5ddf3cad | ||
|
|
809bda02d1 | ||
|
|
2d5ec6ce22 | ||
|
|
a95d0ce9ef | ||
|
|
267d9234e5 | ||
|
|
4686881566 | ||
|
|
101dab0ea4 | ||
|
|
c2d69cb05e | ||
|
|
58f66e0f42 | ||
|
|
0215e1fa28 | ||
|
|
1c0a93acad | ||
|
|
4fcde135e5 | ||
|
|
332dde154f | ||
|
|
8d51205e8f | ||
|
|
ff05e9d7d5 | ||
|
|
516a52c041 | ||
|
|
9daa64741b | ||
|
|
af11fa5150 | ||
|
|
156e9e0e43 | ||
|
|
ef46979bd8 | ||
|
|
b2aa251c47 | ||
|
|
e204a0fce6 | ||
|
|
bb386d3bd7 | ||
|
|
88a225764a | ||
|
|
99d2caa57d | ||
|
|
ade82e3d60 | ||
|
|
7c04e7e06f | ||
|
|
baf51e5959 | ||
|
|
8aad75ed23 | ||
|
|
1792b66b3a | ||
|
|
5e8ac74b2a | ||
|
|
2acc129381 | ||
|
|
0cbb3c2839 | ||
|
|
539d2e80f1 | ||
|
|
f9e28004a0 | ||
|
|
b7cfcc9272 | ||
|
|
4b6d46fd74 | ||
|
|
b45d8bf221 | ||
|
|
f7d107fc0c | ||
|
|
b14d694e1e | ||
|
|
8d2333006a | ||
|
|
e413619c26 | ||
|
|
03f66a922d | ||
|
|
b115bdafe7 | ||
|
|
0444fdc379 | ||
|
|
c617bba959 | ||
|
|
8da1cfeeb7 | ||
|
|
fcfc2c2e10 | ||
|
|
a753905ee4 | ||
|
|
2a7babce68 | ||
|
|
60d1a27079 | ||
|
|
4a2a184db1 | ||
|
|
45fb735320 | ||
|
|
3eb9e7050f | ||
|
|
26aed9351e | ||
|
|
b1ffbc49c9 | ||
|
|
6d6111de2a | ||
|
|
cc8ce32c61 | ||
|
|
4c94bb0ad5 | ||
|
|
af19180ff0 | ||
|
|
a175aa93e7 | ||
|
|
a78863fde1 | ||
|
|
0d6cbd9093 | ||
|
|
1aaf89ff2c | ||
|
|
295ea97544 | ||
|
|
33103b209d | ||
|
|
fab12dca0b | ||
|
|
c390801c4c | ||
|
|
e548abd332 | ||
|
|
0a5b24be2b | ||
|
|
7f41cafffc | ||
|
|
d66f981be6 | ||
|
|
b66a265726 | ||
|
|
c695f91198 | ||
|
|
11cbc0b40b | ||
|
|
87d91aeef3 | ||
|
|
6a6dfcbaff | ||
|
|
9553627136 | ||
|
|
a4a8894d22 | ||
|
|
bf217dcf85 | ||
|
|
484ee9f065 | ||
|
|
bba82ccd6c | ||
|
|
fb122df5f5 | ||
|
|
be8c3131d8 | ||
|
|
9341332379 | ||
|
|
83bcb441bf | ||
|
|
a074d16297 | ||
|
|
89ab4aff9c | ||
|
|
0ac67bfe76 | ||
|
|
0d61192c67 | ||
|
|
a1aa9c17ff | ||
|
|
d0faa36eef | ||
|
|
22c8153ba8 | ||
|
|
6602c580f4 | ||
|
|
431a9b7023 | ||
|
|
d426226bce | ||
|
|
09afdc2553 | ||
|
|
ca83905d9f | ||
|
|
086295adbb | ||
|
|
81cf1508e0 | ||
|
|
8484193151 | ||
|
|
d10fbf8263 | ||
|
|
f73b3d71bf | ||
|
|
d48d775a59 | ||
|
|
f716bfc58f | ||
|
|
97b388747a | ||
|
|
898fa203ad | ||
|
|
c02c6ee58c | ||
|
|
23b04b5069 | ||
|
|
0ed0d17f38 | ||
|
|
645ede869f | ||
|
|
f5e48c850d | ||
|
|
9bd035a19d | ||
|
|
2e428f906c | ||
|
|
b702ae482b | ||
|
|
b8ca41b45f | ||
|
|
adc16fdd3d | ||
|
|
b32d0efe6d | ||
|
|
c96acbfa23 | ||
|
|
ffe528467e | ||
|
|
b989698740 | ||
|
|
29e0975832 | ||
|
|
e1e2526322 | ||
|
|
f2e83c37e9 | ||
|
|
debda5d111 | ||
|
|
2c4e819010 | ||
|
|
b3700dabf2 | ||
|
|
fb2979d9ef | ||
|
|
a378d62dfd | ||
|
|
eb5ba72cfc | ||
|
|
c1e9d0ab4f | ||
|
|
181cc47079 | ||
|
|
04eef669f9 | ||
|
|
9167e5363d | ||
|
|
f1c5c9a148 | ||
|
|
69e5627cd7 | ||
|
|
ae3e6c29e3 | ||
|
|
f6da81ac70 | ||
|
|
dd6e212519 | ||
|
|
95bba50223 | ||
|
|
21f7c6c0ad | ||
|
|
d15c30f63b | ||
|
|
db5b7e5db9 | ||
|
|
7c808bb03b | ||
|
|
530b6cc360 | ||
|
|
95012c004f | ||
|
|
59918b9dbc | ||
|
|
b47cca4515 | ||
|
|
5f27019855 | ||
|
|
0b228834c2 | ||
|
|
57979b9287 | ||
|
|
4b85000960 | ||
|
|
d1f34d088b | ||
|
|
3bc9392e5b | ||
|
|
75165803a0 | ||
|
|
afc9c772be | ||
|
|
07450bb83d | ||
|
|
2ff7e83ad9 | ||
|
|
d817fdcfdb | ||
|
|
f3d966897f | ||
|
|
9acaf1c279 | ||
|
|
fd6a0b547f | ||
|
|
c02f355479 | ||
|
|
7d9203ef84 | ||
|
|
e849e4792d | ||
|
|
4565b3af8d | ||
|
|
e5b868e0e9 | ||
|
|
489450d3fa | ||
|
|
73afab67c8 | ||
|
|
c61f77029b | ||
|
|
79702aba65 | ||
|
|
1e366ff66f | ||
|
|
a0482cf27e | ||
|
|
288a623ab6 | ||
|
|
3b2037a2d4 | ||
|
|
ce536fa3ac | ||
|
|
41883e44e7 | ||
|
|
c3ff201b90 | ||
|
|
e6635cdd77 | ||
|
|
cfc9d79c79 | ||
|
|
fe2c355739 | ||
|
|
04c3429839 | ||
|
|
cabbe0aaf6 | ||
|
|
a7787d87f9 | ||
|
|
79b851189f | ||
|
|
9e972eafb2 | ||
|
|
53a995372f | ||
|
|
17351021b3 | ||
|
|
8ff2c1b6f3 | ||
|
|
45aea2c8ff | ||
|
|
9f5e40283a | ||
|
|
025309ec64 | ||
|
|
bd4850b2b5 | ||
|
|
472e114fb9 | ||
|
|
828bcb1266 | ||
|
|
9897f4eb4b | ||
|
|
e1ef820184 | ||
|
|
b3ad766680 | ||
|
|
74b19dc1f5 | ||
|
|
449bc93307 | ||
|
|
622af17705 | ||
|
|
a42f7f99fe | ||
|
|
3c6bd555b4 | ||
|
|
a4211d5f11 | ||
|
|
090c5bcf00 | ||
|
|
82850d7f66 | ||
|
|
86112351a6 | ||
|
|
ce789d1e3e | ||
|
|
73fb1b8074 | ||
|
|
8e15fe51b6 | ||
|
|
aa954b776d | ||
|
|
76f6eb1434 | ||
|
|
e38308bac3 | ||
|
|
e804f592de | ||
|
|
6e0a0c5c4a | ||
|
|
122590fc68 | ||
|
|
c806366469 | ||
|
|
0d3bd6e2e8 | ||
|
|
beac0b1acd | ||
|
|
1cc9c7a469 | ||
|
|
17db0805a7 | ||
|
|
2f53972c85 | ||
|
|
9ac780102e | ||
|
|
60b80083e0 | ||
|
|
8597b04c41 | ||
|
|
6a60c46a99 | ||
|
|
5c2163a1a7 | ||
|
|
a49bcd618d | ||
|
|
d76b41afe7 | ||
|
|
ab2b635a77 | ||
|
|
7072c7bd45 | ||
|
|
530c5500c3 | ||
|
|
8870b577d0 | ||
|
|
7d85ab471a | ||
|
|
3205cbf932 | ||
|
|
b9fb4de878 | ||
|
|
bcd7096e1d | ||
|
|
b206f2846a | ||
|
|
8a8bc6aa34 | ||
|
|
bce7c258c3 | ||
|
|
cea7278faf | ||
|
|
d7a9b98ce8 | ||
|
|
7dcde12e2e | ||
|
|
ba2a5c4744 | ||
|
|
39ac3c38bf | ||
|
|
61f751a1db | ||
|
|
5f2193f2e4 | ||
|
|
98b714f84a | ||
|
|
2a0198b618 | ||
|
|
cd9f8f3119 | ||
|
|
37b569eca6 | ||
|
|
d317111d20 | ||
|
|
3f1d216d28 | ||
|
|
0ca3d73ae9 | ||
|
|
1972d531b9 | ||
|
|
5006c79a00 | ||
|
|
8788ee1aa7 | ||
|
|
17ba73b0b8 | ||
|
|
0407df83b7 | ||
|
|
f140aadafe | ||
|
|
b41c6185e4 | ||
|
|
aa3d7f5e21 | ||
|
|
efadf6fdf4 | ||
|
|
12863e9b04 | ||
|
|
1843618c99 | ||
|
|
4e5071fd68 | ||
|
|
6e918edce1 | ||
|
|
80ff5a18b1 | ||
|
|
d112cc585f | ||
|
|
3fec33f56c | ||
|
|
68674deb00 | ||
|
|
a9e530721d | ||
|
|
03e9034a98 | ||
|
|
6970c5ce97 | ||
|
|
10b3803a7f | ||
|
|
a7e8c82633 | ||
|
|
6d4c4295b3 | ||
|
|
47edc356ad | ||
|
|
b551e3a2ad | ||
|
|
a9c32bc2e2 | ||
|
|
60c7be87f8 | ||
|
|
2bac78b4a4 | ||
|
|
c4769eeebb | ||
|
|
51341f6255 | ||
|
|
c7a32dc91b | ||
|
|
3623678c93 | ||
|
|
a5d516e179 | ||
|
|
2045905c9b | ||
|
|
26c027a075 | ||
|
|
b86ee20f3f | ||
|
|
50c75e9684 | ||
|
|
d87c3d5323 | ||
|
|
247f674749 | ||
|
|
74fe03414c | ||
|
|
65d213c494 | ||
|
|
05a51346f9 | ||
|
|
6c525e1fe6 | ||
|
|
5be00e28dd | ||
|
|
d81dbbd951 | ||
|
|
83dee9d667 | ||
|
|
7d79cff66f | ||
|
|
0a63bd0fc6 | ||
|
|
55d8c8c928 | ||
|
|
681f7041dc | ||
|
|
d5f15e6408 | ||
|
|
70d510dff8 | ||
|
|
2a5c128267 | ||
|
|
e5a1052089 | ||
|
|
8c64f6221e | ||
|
|
0869a2acc3 | ||
|
|
e7ea827f02 | ||
|
|
84b6ece31d | ||
|
|
1bcc5b6582 | ||
|
|
c8c025ac34 | ||
|
|
d82d70ac97 | ||
|
|
3e86fd4e57 | ||
|
|
964eda13cc | ||
|
|
c16815b16d | ||
|
|
74ee8ec459 | ||
|
|
22ea72c1b2 | ||
|
|
613dc4184a | ||
|
|
9a471aff1b | ||
|
|
e69e42cabc | ||
|
|
1281426075 | ||
|
|
8b1baafddf | ||
|
|
ee65d7e5fa | ||
|
|
df0ae205cd | ||
|
|
1cbd384569 | ||
|
|
e47527087e | ||
|
|
517a2db9d8 | ||
|
|
fbf993566d | ||
|
|
25bea47872 | ||
|
|
78f22e895e | ||
|
|
fa3925cd74 | ||
|
|
d9418d5ce1 | ||
|
|
103f9e0b85 | ||
|
|
a2fc3d5b71 | ||
|
|
c66d64b9d8 | ||
|
|
0dd67f40ba | ||
|
|
f5dc39ddf0 | ||
|
|
6b47776b11 | ||
|
|
2b73c7f9e4 | ||
|
|
4558ac66fa | ||
|
|
d0a98949f5 | ||
|
|
e13e7f286c | ||
|
|
0045e3f9f7 | ||
|
|
ff608b72a2 | ||
|
|
19c3c8056b | ||
|
|
d31c24bbf7 | ||
|
|
768f9497fd | ||
|
|
20be691f36 | ||
|
|
3dd3f045e6 | ||
|
|
6d3538a35b | ||
|
|
1a0bfecb5f | ||
|
|
5d3b4c8efd | ||
|
|
8adc0dd7eb | ||
|
|
2cb71c5352 | ||
|
|
b6068f4519 | ||
|
|
21a6b0143d | ||
|
|
28949853f7 | ||
|
|
65c83393bb | ||
|
|
960988ddcd | ||
|
|
fb99dca83e | ||
|
|
e786243738 | ||
|
|
cec0e2cbfb | ||
|
|
dadd7d4693 | ||
|
|
dc558f906c | ||
|
|
8184e99409 | ||
|
|
ac87629550 | ||
|
|
1c231b703a | ||
|
|
a66b11e6ec | ||
|
|
4f24c4ea78 | ||
|
|
a800b148a2 | ||
|
|
1710e15e49 | ||
|
|
a332d4935d | ||
|
|
9b855c7de0 | ||
|
|
e8be80ccd7 | ||
|
|
c661da57d8 | ||
|
|
4165f58414 | ||
|
|
7126b7bca0 | ||
|
|
a7f647e3ca | ||
|
|
e901a87afd | ||
|
|
9eb237b3af | ||
|
|
909ea9dc99 | ||
|
|
86013328d6 | ||
|
|
0c80cd017f | ||
|
|
2b8a0f8cd8 | ||
|
|
e1926c973e | ||
|
|
f515f680a4 | ||
|
|
effba9fdec | ||
|
|
388f064307 | ||
|
|
bb15485965 | ||
|
|
cb9db5dff1 | ||
|
|
3b644a0af1 | ||
|
|
8ce2ecfaac | ||
|
|
bdd9ca76ee | ||
|
|
44ae50083d | ||
|
|
e5d999c755 | ||
|
|
4e90ebc7d9 | ||
|
|
dbf0458575 | ||
|
|
e6e44b8747 | ||
|
|
2b702528fd | ||
|
|
23144ff204 | ||
|
|
764b6c78c5 | ||
|
|
051e19e9c1 | ||
|
|
ad99850192 | ||
|
|
c93eeb3607 | ||
|
|
551cf8442f | ||
|
|
90d506ee7c | ||
|
|
45bca78e75 | ||
|
|
11faca1940 | ||
|
|
47b179dec4 | ||
|
|
05efbe0af8 | ||
|
|
48a7587c5a | ||
|
|
ff82145633 | ||
|
|
dcc703f454 | ||
|
|
07f66fb15a | ||
|
|
c0fb7d9f9a | ||
|
|
2b6fc6dd3a | ||
|
|
e147495fb9 | ||
|
|
b2e65a19a2 | ||
|
|
44638ccc1a | ||
|
|
5f4b2cfa52 | ||
|
|
0bc2301530 | ||
|
|
d1eda38745 | ||
|
|
dc10421531 | ||
|
|
00f5975a3c | ||
|
|
b41f444013 | ||
|
|
89b4060a06 | ||
|
|
98ca001da6 | ||
|
|
b0b41711d4 | ||
|
|
3f691d6977 | ||
|
|
977159e572 | ||
|
|
9e15e754c2 | ||
|
|
c085ee47ed | ||
|
|
a5ca118bbf | ||
|
|
521122fd4f | ||
|
|
86933d8150 | ||
|
|
976f34c19f | ||
|
|
a56340663c | ||
|
|
e3900e9f99 | ||
|
|
e8b1362172 | ||
|
|
f6d857b5b5 | ||
|
|
aa9f43dea1 | ||
|
|
513ab62ce7 | ||
|
|
a020dea277 | ||
|
|
19dd447dcb | ||
|
|
eb1abd9222 | ||
|
|
9ab7c8d9e5 | ||
|
|
1e592b4681 | ||
|
|
40a08d0d84 | ||
|
|
517e72f442 | ||
|
|
ea51df432d | ||
|
|
c27bfc515e | ||
|
|
7fad0b0f51 | ||
|
|
76663f819b | ||
|
|
666760f0cf | ||
|
|
2d73f2f46e | ||
|
|
c8e54bbcd0 | ||
|
|
76a4dce66a | ||
|
|
c102d602b3 | ||
|
|
e711490f6c | ||
|
|
c801cdbb3b | ||
|
|
9d638671bb | ||
|
|
4a703481ba | ||
|
|
897cbb9826 | ||
|
|
bb710cc360 | ||
|
|
5eab07d8d6 | ||
|
|
894a30b9bd | ||
|
|
e8579771a5 | ||
|
|
09670a4475 | ||
|
|
ff783cf9a5 | ||
|
|
46d31c3ee3 | ||
|
|
3e8c821c02 | ||
|
|
50eaf712a9 | ||
|
|
f476747ade | ||
|
|
d8d881085f | ||
|
|
fd6e1b3046 | ||
|
|
d6697924cb | ||
|
|
3001926ae4 | ||
|
|
578451fcfa | ||
|
|
d57bdf6dc3 | ||
|
|
0309fac592 | ||
|
|
9ecd320c8c | ||
|
|
c326566bd2 | ||
|
|
4f10dbb896 | ||
|
|
cb6d377796 | ||
|
|
b5f58b0a03 | ||
|
|
9ee5fae476 | ||
|
|
81feb2fd5e | ||
|
|
75a76fb184 | ||
|
|
21f1ccbfb4 | ||
|
|
0f5a7cda6c | ||
|
|
acd7bce903 | ||
|
|
1afacd28a1 | ||
|
|
6e171d19f0 | ||
|
|
66921499ad | ||
|
|
249972c7fd | ||
|
|
dae0e233b8 | ||
|
|
8bb566a250 | ||
|
|
6a25bbeef0 | ||
|
|
6286ac4a3b | ||
|
|
447f99ea15 | ||
|
|
587d4dc8b6 | ||
|
|
b5613ffcf5 | ||
|
|
1fe82b1312 | ||
|
|
a4daa78c0b | ||
|
|
618bdfc917 | ||
|
|
8e68aa0ccd | ||
|
|
df3757657e | ||
|
|
0eea1a1d89 | ||
|
|
15dcdca6fc | ||
|
|
7a6aef03e7 | ||
|
|
c61f3b9110 | ||
|
|
42fecc7491 | ||
|
|
0acca6dd64 | ||
|
|
ec00d1b710 | ||
|
|
f093e90c23 | ||
|
|
3d1f6d9b82 | ||
|
|
9bdcbb9008 | ||
|
|
491e6c8730 | ||
|
|
d32d268d97 | ||
|
|
30c447b9f3 | ||
|
|
2def8f35ad | ||
|
|
f2055daf1a | ||
|
|
944571ea89 | ||
|
|
f7c601b863 | ||
|
|
7315da2ccb | ||
|
|
2f7f6a0b58 | ||
|
|
3f43051c35 | ||
|
|
535c35310d | ||
|
|
8fbe6a4511 | ||
|
|
07ff0f1026 | ||
|
|
a080288e3e | ||
|
|
71bd87f293 | ||
|
|
574e2abba8 | ||
|
|
cffa772801 | ||
|
|
66bd793306 | ||
|
|
0eb37883ca | ||
|
|
356384ab05 | ||
|
|
8c2c6877b6 | ||
|
|
d1d40d8a60 | ||
|
|
b026a0a372 | ||
|
|
73bcd0058a | ||
|
|
0cf396e5d6 | ||
|
|
1bc09d4292 | ||
|
|
97d0c51db1 | ||
|
|
ed1c11267c | ||
|
|
a3e1ac896f | ||
|
|
37d9eb2752 | ||
|
|
05e267a0bd | ||
|
|
d1f0a29a02 | ||
|
|
fb2e780c56 | ||
|
|
ba3257f850 | ||
|
|
215d5e9adf | ||
|
|
5392b32d5c | ||
|
|
4dd0a75914 | ||
|
|
a2212002ae | ||
|
|
91ccee3513 | ||
|
|
2a593d5d0a | ||
|
|
a93b3d79aa | ||
|
|
938ab32cda | ||
|
|
6f5ab05345 | ||
|
|
95f8647f09 | ||
|
|
06c8caa3cc | ||
|
|
d206a562df | ||
|
|
a0a290e481 | ||
|
|
266ff0c520 | ||
|
|
931bf7da16 | ||
|
|
fe4a26d034 | ||
|
|
dca70a99ad | ||
|
|
1a24a73ccd | ||
|
|
ae163319e0 | ||
|
|
65864e273b | ||
|
|
199b778d2b | ||
|
|
70e3c47120 | ||
|
|
eddc5d6524 | ||
|
|
fae3068c25 | ||
|
|
b9014b2a60 | ||
|
|
6b07b6407c | ||
|
|
a10b987f1c | ||
|
|
1f16310797 | ||
|
|
0fd59063d9 | ||
|
|
aab477b874 | ||
|
|
098d939653 | ||
|
|
7d830362a7 | ||
|
|
0db1660369 | ||
|
|
c471a70b35 | ||
|
|
6aef6f2c11 | ||
|
|
000f0bf2f1 | ||
|
|
0f1c08b43a | ||
|
|
76ffb5cd53 | ||
|
|
23d245d43c | ||
|
|
aabc86fc01 | ||
|
|
cebd7fb545 | ||
|
|
8337689640 | ||
|
|
0263130126 | ||
|
|
c472d740ec | ||
|
|
0fd244eee0 | ||
|
|
7dcb6f66da | ||
|
|
14956d27bd | ||
|
|
420be2c44f | ||
|
|
3bb3a902b3 | ||
|
|
2b138ac940 | ||
|
|
b6eeef1db6 | ||
|
|
469dda7d85 | ||
|
|
3c2933d587 | ||
|
|
3b128c8512 | ||
|
|
fb1be7b003 | ||
|
|
e0aa52ed27 | ||
|
|
64ac619b46 | ||
|
|
902472be32 | ||
|
|
cb024b00d9 | ||
|
|
75de616465 | ||
|
|
c12d8e2f46 | ||
|
|
d8087660e6 | ||
|
|
87a8e6e20c | ||
|
|
b599a7607d | ||
|
|
a6b22d1f41 | ||
|
|
8e59761b03 | ||
|
|
8599506497 | ||
|
|
e4ab10fe92 | ||
|
|
171c297d1b | ||
|
|
5eccb0ed49 | ||
|
|
f326de2686 | ||
|
|
2ca6b7f929 | ||
|
|
79afae17e7 | ||
|
|
cb4d9dc365 | ||
|
|
4bf8b98681 | ||
|
|
7f1371ec00 | ||
|
|
cb3db8ae16 | ||
|
|
cf2e37f92d | ||
|
|
92319b0e31 | ||
|
|
d4ff653937 | ||
|
|
7df12930ef | ||
|
|
9ba70951d5 | ||
|
|
2d25369d06 | ||
|
|
affcaf1c02 | ||
|
|
7e314c0d7a | ||
|
|
1266ca314c | ||
|
|
7394598aff | ||
|
|
b02a710bc5 | ||
|
|
ce6966a823 | ||
|
|
689183edc0 | ||
|
|
43113c7844 | ||
|
|
fb8879a919 | ||
|
|
136b9f9138 | ||
|
|
eea326561e | ||
|
|
e3781c68be | ||
|
|
d2927dc68f | ||
|
|
ca95d47127 | ||
|
|
a5a0c94a2c | ||
|
|
cfa49ee757 | ||
|
|
8921baecd0 | ||
|
|
8b78477c69 | ||
|
|
14633724f2 | ||
|
|
8d3ea9c50f | ||
|
|
32a58b1adb | ||
|
|
f01a31ce56 | ||
|
|
3f69c3a2ab | ||
|
|
e0f3d6d0d7 | ||
|
|
a8f148acac | ||
|
|
0c57af40dc | ||
|
|
0714be6b73 | ||
|
|
b5ce6f0bb0 | ||
|
|
67d59067eb | ||
|
|
f1984a103d | ||
|
|
41fd7a8a56 | ||
|
|
14ac139d03 | ||
|
|
97b1ae5ee9 | ||
|
|
15e0763ed5 | ||
|
|
3ce5d14210 | ||
|
|
2c884e2ca5 | ||
|
|
c204fb9b14 | ||
|
|
69721d2d04 | ||
|
|
73b14d3826 | ||
|
|
7ca6f24e6c | ||
|
|
2c3e3f0d43 | ||
|
|
3b68c6902c | ||
|
|
c5926fcf2b | ||
|
|
e6546eea85 | ||
|
|
892357cc2c | ||
|
|
7c6fb26eb7 | ||
|
|
491530ad60 | ||
|
|
6667c1f03d | ||
|
|
e985fc41ce | ||
|
|
508eb04e94 | ||
|
|
68e9368bb3 | ||
|
|
db152e6790 | ||
|
|
6bf2f5611a | ||
|
|
11a13967d5 | ||
|
|
05fe423ef1 | ||
|
|
6e0165986f | ||
|
|
f167e11905 | ||
|
|
727cae902a | ||
|
|
f38f9a47da | ||
|
|
7708d3d157 | ||
|
|
4c64c5ad05 | ||
|
|
534ce179ec | ||
|
|
1b73bacde1 | ||
|
|
a13ad32ec5 | ||
|
|
13a6c86077 | ||
|
|
5fc1b760f4 | ||
|
|
a6d78d9af7 | ||
|
|
48669e96d1 | ||
|
|
071161176e | ||
|
|
f046d76c59 | ||
|
|
53ab224fba | ||
|
|
5faf1f27de | ||
|
|
f38b970ea2 | ||
|
|
5dbccfcbbd | ||
|
|
de5249f99e | ||
|
|
420320f896 | ||
|
|
06ac2d1805 | ||
|
|
cdc0b7a649 | ||
|
|
6c7be51221 | ||
|
|
1159137c0d | ||
|
|
a98cb040b7 | ||
|
|
170213e6d4 | ||
|
|
129c6d2d1e | ||
|
|
ad75ee8c50 | ||
|
|
e94b99da65 | ||
|
|
4f47709d32 | ||
|
|
71ea8d7148 | ||
|
|
919223cd2f | ||
|
|
fd8cace362 | ||
|
|
18d937d83e | ||
|
|
1d19868119 | ||
|
|
840e634161 | ||
|
|
731eef8c2f | ||
|
|
135ee018a9 | ||
|
|
7633392eea | ||
|
|
daea0f3e5e | ||
|
|
c525c80b49 | ||
|
|
311fb04647 | ||
|
|
219bd9c10e | ||
|
|
6d704eadd7 | ||
|
|
32da1993e1 | ||
|
|
d4cad980e5 | ||
|
|
53340ab22c | ||
|
|
2d3767a35c | ||
|
|
aaa9bc906e | ||
|
|
7503317d49 | ||
|
|
3fc93a33c8 | ||
|
|
d7d1d54a0b | ||
|
|
34b9344084 | ||
|
|
779f3a8a61 | ||
|
|
8c1690ef65 | ||
|
|
85f32d9a97 | ||
|
|
54c7ec5873 | ||
|
|
8d260708d3 | ||
|
|
f8009e4b84 | ||
|
|
a2260ee6b2 | ||
|
|
6193eafb7b | ||
|
|
a4eea3325f | ||
|
|
b93e61b758 | ||
|
|
14448ad97e | ||
|
|
3d17f0d588 | ||
|
|
ee5ea09cbc | ||
|
|
aac8ca97ed | ||
|
|
e4d6da47a4 | ||
|
|
9f7dbb394e | ||
|
|
f98063b97a | ||
|
|
ed607bdc37 | ||
|
|
a3c3e4cbd4 | ||
|
|
bffb8a034e | ||
|
|
8242d4fe92 | ||
|
|
279b682ac2 | ||
|
|
43ff476d98 | ||
|
|
28201a6c38 | ||
|
|
6923800081 | ||
|
|
700b83572e | ||
|
|
6e53cb2deb | ||
|
|
8e04182b3f | ||
|
|
9fd6d1b81f | ||
|
|
60379d9ae6 | ||
|
|
29ba1d4809 | ||
|
|
dc4b064c73 | ||
|
|
0f20888563 | ||
|
|
2361f8f9d3 | ||
|
|
feba54d5d2 | ||
|
|
3cecab25c7 | ||
|
|
814851ba60 | ||
|
|
6333cc3bea | ||
|
|
00bf9c569a | ||
|
|
6def1bce25 | ||
|
|
3ab5c90d7c | ||
|
|
0507d6923e | ||
|
|
e85baa8068 | ||
|
|
cbed5a0c14 | ||
|
|
e0628ec6c9 | ||
|
|
82637ff072 | ||
|
|
a95a18a8b5 | ||
|
|
d36637ed13 | ||
|
|
dd5e5dcda7 | ||
|
|
0ff7fe8479 | ||
|
|
8c638bcfd8 | ||
|
|
0bd252e7f5 | ||
|
|
ddd3073132 | ||
|
|
1788422abc | ||
|
|
6210630ce2 | ||
|
|
e5af7d11cc | ||
|
|
5777808aa9 | ||
|
|
a97e6833a3 | ||
|
|
79408ba0c4 | ||
|
|
25dd89ed17 | ||
|
|
dd61d0d395 | ||
|
|
65a92746d1 | ||
|
|
695e87689c | ||
|
|
8997e786da | ||
|
|
239f1afbbd | ||
|
|
df09b5baac | ||
|
|
de4aa78fd6 | ||
|
|
8175d4c31f | ||
|
|
2694bd37ea | ||
|
|
954d2e64ef | ||
|
|
c2be70b61d | ||
|
|
d701a7b04e | ||
|
|
2925aa6261 | ||
|
|
4ebd43104c | ||
|
|
2ebe8d0ed4 | ||
|
|
b26bce8fde | ||
|
|
dc31ee4f7e | ||
|
|
1b3b0f199d | ||
|
|
0800cfccb6 | ||
|
|
ea0ff6cbf7 | ||
|
|
341fefda01 | ||
|
|
8550c071a2 | ||
|
|
6b1c555d38 | ||
|
|
64ce90d5ca | ||
|
|
415526d23e | ||
|
|
b2ebb65c26 | ||
|
|
7a7e3544cf | ||
|
|
9fbc7470c1 | ||
|
|
056b38fd2a | ||
|
|
23211dd1ee | ||
|
|
b4ad0ebf52 | ||
|
|
0ee6dd3f77 | ||
|
|
70a422d354 | ||
|
|
9d7975ce33 | ||
|
|
9b5a1bedc0 | ||
|
|
1518168843 | ||
|
|
f0cfe30a36 | ||
|
|
219bcb3521 | ||
|
|
c7e87bc16a | ||
|
|
66c15c8639 | ||
|
|
00ccecac9c | ||
|
|
102c1fecb6 | ||
|
|
9d4d92167a | ||
|
|
e7fde3bacb | ||
|
|
c0fe9c179c | ||
|
|
929c684977 | ||
|
|
02e776bfe5 | ||
|
|
0c46cc6843 | ||
|
|
344f4afdbd | ||
|
|
4291912577 | ||
|
|
1e5c4c9b7c | ||
|
|
06ec72a064 | ||
|
|
31a823bc34 | ||
|
|
dc6f1c4dd2 | ||
|
|
fc8e3d1787 | ||
|
|
8a25471fbb | ||
|
|
ad06d9bb4a | ||
|
|
ec95ce8329 | ||
|
|
ab4fb6e69c | ||
|
|
238e2d0280 | ||
|
|
2c8a581923 | ||
|
|
e878d7d439 | ||
|
|
4f12660961 | ||
|
|
80a7e4175b | ||
|
|
b4f17e67d0 | ||
|
|
5df4d2f2fd | ||
|
|
ffc7715f1b | ||
|
|
a6cca3094d | ||
|
|
b82e0749b7 | ||
|
|
5c1d2b3393 | ||
|
|
4841926f83 | ||
|
|
eebf1a5126 | ||
|
|
028207022a | ||
|
|
c9fa49d40f | ||
|
|
5d356d509c | ||
|
|
22b361c281 | ||
|
|
38b98a97d1 | ||
|
|
9599f54b06 | ||
|
|
e74333cbd3 | ||
|
|
6a7e1d920a | ||
|
|
0dc714f947 | ||
|
|
62391d3074 | ||
|
|
c507efd920 | ||
|
|
6641d428a2 | ||
|
|
b8afc27e2f | ||
|
|
d577428ac8 | ||
|
|
fba8019f98 | ||
|
|
6f922ac3ac | ||
|
|
44cf8efc06 | ||
|
|
1990b893e5 | ||
|
|
684bb736bc | ||
|
|
01d6735803 | ||
|
|
4e674e0380 | ||
|
|
3acd966241 | ||
|
|
ee190601ee | ||
|
|
240d1423a3 | ||
|
|
e36f6d25b8 | ||
|
|
9339019308 | ||
|
|
9f5a2d1eb3 | ||
|
|
a0ade9ea31 | ||
|
|
71c2db0829 | ||
|
|
f33a15dc4e | ||
|
|
c330f4a35e | ||
|
|
fe25c9c483 | ||
|
|
f6fcff6a73 | ||
|
|
d1146b4fbc | ||
|
|
9be4a91028 | ||
|
|
6c3a4b8ffc | ||
|
|
faabcd8cb7 | ||
|
|
fc7319564e | ||
|
|
061de66397 | ||
|
|
55f21e077a | ||
|
|
821f98eb46 | ||
|
|
3ca8164326 | ||
|
|
88ce841bf6 | ||
|
|
b94d401d09 | ||
|
|
1c3b25d026 | ||
|
|
84ec3d5353 | ||
|
|
bde58fb677 | ||
|
|
651e22b14a | ||
|
|
111b7e204f | ||
|
|
9ff3791d9e | ||
|
|
7380df0256 | ||
|
|
7e32fa1311 | ||
|
|
0472147e9a | ||
|
|
68f282ee83 | ||
|
|
4909479c42 | ||
|
|
82e180cca8 | ||
|
|
aff9114c35 | ||
|
|
f656f08f9b | ||
|
|
967e3028fd | ||
|
|
428af55bd9 | ||
|
|
340725d395 | ||
|
|
f8030393c8 | ||
|
|
f6197d0a8d | ||
|
|
969ea5e6ee | ||
|
|
d4c6268a46 | ||
|
|
aeda76c058 | ||
|
|
9894d0672f | ||
|
|
1964547eb3 | ||
|
|
d2e884b1d9 | ||
|
|
80b3a5b1d4 | ||
|
|
a6a9989fcf | ||
|
|
bce63b0dab | ||
|
|
5ca0b6b18e | ||
|
|
19c0508b83 | ||
|
|
1891c95ae3 | ||
|
|
a722ec1c37 | ||
|
|
0c3b5439e9 | ||
|
|
963e9d4bb5 | ||
|
|
4dd7c63cab | ||
|
|
03a892aded | ||
|
|
b3c1c0bbe8 | ||
|
|
5a064b0979 | ||
|
|
f06e565441 | ||
|
|
41fdafa3fb | ||
|
|
27c528a6b3 | ||
|
|
9623c1fffd | ||
|
|
d4e0347d1d | ||
|
|
74bb057314 | ||
|
|
b2980178d1 | ||
|
|
08a0871168 | ||
|
|
51fa00399d | ||
|
|
7622f7f28f | ||
|
|
d98d693369 | ||
|
|
c7e8692964 | ||
|
|
0431c3fce0 | ||
|
|
411f0e40b6 | ||
|
|
a5d2046a87 | ||
|
|
f8893a7ed3 | ||
|
|
93ac018400 | ||
|
|
6b852d6e1a | ||
|
|
06dc76a78b | ||
|
|
1ff5908a4c | ||
|
|
e2f61636cc | ||
|
|
4db4b5305e | ||
|
|
c550fdaee8 | ||
|
|
d13b7988b7 | ||
|
|
d437f0105a | ||
|
|
b65618030f | ||
|
|
01a2376b74 | ||
|
|
d10ddb17b6 | ||
|
|
c42d489bf7 | ||
|
|
8fef6b8d8c | ||
|
|
35b1178c20 | ||
|
|
c0f95755ff | ||
|
|
b7676a3da2 | ||
|
|
3d65719170 | ||
|
|
18d262c1ae | ||
|
|
e5fedb90a6 | ||
|
|
dc82b384c5 | ||
|
|
2f56e40fb7 | ||
|
|
d719eb356f | ||
|
|
6a34fe5184 | ||
|
|
461961c3be | ||
|
|
39869bcdc5 | ||
|
|
a10d7ae5b9 | ||
|
|
4ed45248eb | ||
|
|
6e4b255be5 | ||
|
|
2e56c226db | ||
|
|
844ff402cd | ||
|
|
ec570be178 | ||
|
|
3508cf21c7 | ||
|
|
1f4ddc295a | ||
|
|
3e16593bb7 | ||
|
|
4ef0e054d6 | ||
|
|
61310c50d7 | ||
|
|
6eab838a70 | ||
|
|
52e01c0925 | ||
|
|
97d6e80556 | ||
|
|
d5abadc6d0 | ||
|
|
d08d716966 | ||
|
|
a864b893b8 | ||
|
|
9212505243 | ||
|
|
abbcb6dc72 | ||
|
|
3f49c169bb | ||
|
|
16c8256f0b | ||
|
|
75d94b04aa | ||
|
|
b9c2e7636c | ||
|
|
df29934968 | ||
|
|
3ee4be2e33 | ||
|
|
9172cc4925 | ||
|
|
7f03a86dee | ||
|
|
1603bab1da | ||
|
|
70aae514be | ||
|
|
5fa1185d6d | ||
|
|
3a2a584ad3 | ||
|
|
c42f53d64f | ||
|
|
450e0eacf4 | ||
|
|
aa40e811f1 | ||
|
|
af96f71190 | ||
|
|
9e4cb6ee33 | ||
|
|
5d0748983b | ||
|
|
c4981e4b91 | ||
|
|
3f36c436ad | ||
|
|
db456cbcf1 | ||
|
|
c0b8384319 | ||
|
|
13036539b7 | ||
|
|
5a2e477dba | ||
|
|
f003c7130f | ||
|
|
0558351a12 | ||
|
|
3f20bdaaa2 | ||
|
|
3bf367d630 | ||
|
|
706fc19ab4 | ||
|
|
4fe024041d | ||
|
|
7afbf8b45b | ||
|
|
e1fc44f4e0 | ||
|
|
21fbb545e8 | ||
|
|
6cd08ea8dc | ||
|
|
85efee1432 | ||
|
|
ba9974fe2a | ||
|
|
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 | ||
|
|
babd809fa6 | ||
|
|
54177c7064 | ||
|
|
4884184e4a | ||
|
|
4c7ef593be | ||
|
|
2600e9a805 | ||
|
|
6ac74f5686 | ||
|
|
172c1789a8 | ||
|
|
ffc00b7800 | ||
|
|
f44f015cb9 | ||
|
|
a4dcda16c1 | ||
|
|
9db506ef42 | ||
|
|
007f2caecf | ||
|
|
80a5845695 | ||
|
|
1b5525a8c5 | ||
|
|
22d45b9571 | ||
|
|
773602169d | ||
|
|
b650d3d9e6 | ||
|
|
9b2171088e | ||
|
|
e58ae58e24 | ||
|
|
a11e840d36 | ||
|
|
7d5b20ccfc | ||
|
|
2530d28c9d | ||
|
|
c669bc3e7f | ||
|
|
5943c8975a | ||
|
|
d9f97f6aad | ||
|
|
576521229c | ||
|
|
ac919f72a8 | ||
|
|
85ce2aff47 | ||
|
|
8030db03ad | ||
|
|
1e90470862 | ||
|
|
e37ca97bde | ||
|
|
97f45f5d96 | ||
|
|
0a64caf4c5 | ||
|
|
eee6fc0f10 | ||
|
|
60972e026b | ||
|
|
fd9123610b | ||
|
|
6458653812 | ||
|
|
328d448ab2 | ||
|
|
10aca70879 | ||
|
|
92edc68890 | ||
|
|
4d4af9d74e | ||
|
|
92c21de61d | ||
|
|
f918d34098 | ||
|
|
95e0f551e8 | ||
|
|
43e17f82b0 | ||
|
|
c7417623e6 | ||
|
|
50ed657b0e | ||
|
|
8b5d7028f7 | ||
|
|
aa28b3887f | ||
|
|
739b563bc2 | ||
|
|
a3a68de341 | ||
|
|
57c761aa7d | ||
|
|
75891b2d38 | ||
|
|
44943f6bf8 | ||
|
|
5fdcd2d7c7 | ||
|
|
43e3c84635 | ||
|
|
7f8bb10fc5 | ||
|
|
cc85edafc4 | ||
|
|
878ab33039 | ||
|
|
4b495557cd | ||
|
|
d1fd1cd788 | ||
|
|
f870bb3fad | ||
|
|
719f9d7d48 | ||
|
|
fd811bfd1b | ||
|
|
6837cd2917 | ||
|
|
f778a263a7 | ||
|
|
007f66d86e | ||
|
|
0e32393acb | ||
|
|
20729242f9 | ||
|
|
91655a855d | ||
|
|
9f2f343f76 | ||
|
|
6c1d164330 | ||
|
|
937fee9019 | ||
|
|
023a798ac1 | ||
|
|
07d61f6d47 | ||
|
|
304f63aedf | ||
|
|
30190f373a | ||
|
|
b51b094cc1 | ||
|
|
f4a2f344a7 | ||
|
|
1e7214a86b | ||
|
|
f8fd8b3585 | ||
|
|
644d62c915 | ||
|
|
741ec36ee1 | ||
|
|
a08d7bb1b2 | ||
|
|
16ae77ca1c | ||
|
|
a5bf3a8407 | ||
|
|
cd0306d513 | ||
|
|
b29d0b8276 | ||
|
|
3ee88fd8fe | ||
|
|
bc9c93b180 | ||
|
|
e49d10ab22 | ||
|
|
059946d59e | ||
|
|
6211760922 | ||
|
|
167958c002 | ||
|
|
8b16ffb629 | ||
|
|
b5193162bf | ||
|
|
bc34c237b6 | ||
|
|
d9824d26d2 | ||
|
|
8d08b55e69 | ||
|
|
503c844971 | ||
|
|
deff356910 | ||
|
|
883ebbf267 | ||
|
|
cd45116dce | ||
|
|
d80362c4b8 | ||
|
|
384e06d6fe | ||
|
|
e6f44a70d0 | ||
|
|
0ca90ee7e8 | ||
|
|
59a56c803a | ||
|
|
1e0b44bdc5 | ||
|
|
2f3296bada | ||
|
|
434d8e0977 | ||
|
|
0a89eaaf62 | ||
|
|
cea2f81b86 | ||
|
|
86b612f3b5 | ||
|
|
d425e5eb6a | ||
|
|
183fd33f3f | ||
|
|
8c82d3e747 | ||
|
|
7b495f3d81 | ||
|
|
3ea7f1cb03 | ||
|
|
2a13fe05c6 | ||
|
|
2c4c899179 | ||
|
|
760fb32016 | ||
|
|
278f40471b | ||
|
|
20ca09c730 | ||
|
|
568a71cdbe | ||
|
|
753a5f7cb2 | ||
|
|
96e13786cd | ||
|
|
5d6592f296 | ||
|
|
534dd331b9 | ||
|
|
b3b56fcafd | ||
|
|
671fd50cfb | ||
|
|
eaf19643a9 | ||
|
|
a582a3781b | ||
|
|
e0d90e0b21 | ||
|
|
a73189338c | ||
|
|
1e414dd370 | ||
|
|
5ea03c71c0 | ||
|
|
d7a46f089e | ||
|
|
6e33181f05 | ||
|
|
622f8f8158 | ||
|
|
821b0f0f92 | ||
|
|
471b217e99 | ||
|
|
adda0eff4a | ||
|
|
2001ca6566 | ||
|
|
b9a783d7d7 | ||
|
|
eb9ee9f41e | ||
|
|
fae14ad283 | ||
|
|
4b5ac3f926 | ||
|
|
72e5acfb86 | ||
|
|
16c6e17a49 | ||
|
|
ac31671914 | ||
|
|
4b283242fe | ||
|
|
353ea0fbbe | ||
|
|
fc941f55ef | ||
|
|
12600a8cbd | ||
|
|
33fa9542e0 | ||
|
|
d872ea32af | ||
|
|
46bb2d1367 | ||
|
|
403ddd603f | ||
|
|
7907838c24 | ||
|
|
15bd79186a | ||
|
|
4555b77204 | ||
|
|
dd3c612dec | ||
|
|
09b6698de8 | ||
|
|
27ee156706 | ||
|
|
48c3d1fa4a | ||
|
|
286254c5cd | ||
|
|
82cd51f5f4 | ||
|
|
08bf993146 | ||
|
|
a55bcae3ec | ||
|
|
607a14e921 | ||
|
|
c71387ad00 | ||
|
|
c095c28618 | ||
|
|
cae1188ff8 | ||
|
|
7e599c51f8 | ||
|
|
6ccb9d2dc2 | ||
|
|
1d00ed463e | ||
|
|
c99054e479 | ||
|
|
85a9e0d0bc | ||
|
|
8b4ea3c80c | ||
|
|
30dec34b72 | ||
|
|
a3d2df7c45 | ||
|
|
034f338f45 | ||
|
|
1d84346705 | ||
|
|
6e916ebd45 | ||
|
|
a993bed8dc | ||
|
|
aa6f65ee1f | ||
|
|
573931930c | ||
|
|
252bb69808 | ||
|
|
0175c8ab8a | ||
|
|
f78bb2078d | ||
|
|
bc028a63cd | ||
|
|
4b04f2b918 | ||
|
|
887a3b0922 | ||
|
|
3df78fa387 | ||
|
|
c36ac5baba | ||
|
|
d8e33fe596 | ||
|
|
80b7e2e188 | ||
|
|
14b430a168 | ||
|
|
22aa4cbb9f | ||
|
|
71bb5b850e | ||
|
|
066c830a43 | ||
|
|
760107becf | ||
|
|
8dad49e385 | ||
|
|
518e5db55b | ||
|
|
31a3c1cf33 | ||
|
|
e1b4975a11 | ||
|
|
f8a5e8bfc7 | ||
|
|
a656ad5cd2 | ||
|
|
b43e4fae86 | ||
|
|
1f17aa394e | ||
|
|
a1d7bc558c | ||
|
|
de31fc320c | ||
|
|
685de847c4 | ||
|
|
40751f267b | ||
|
|
3e1941a561 | ||
|
|
8e27ad3547 | ||
|
|
c4f5db9c84 | ||
|
|
19896e1fae | ||
|
|
23678b814d | ||
|
|
13fe1f2ea2 | ||
|
|
c24d6a0785 | ||
|
|
b2f3fd56f4 | ||
|
|
b82d6cec31 | ||
|
|
c5ff962ea1 | ||
|
|
4aa56c1a7f | ||
|
|
681279cb2b | ||
|
|
c4ea879651 | ||
|
|
8cdf9d2ddc | ||
|
|
daa959e353 | ||
|
|
d5cdff5ec9 | ||
|
|
109eb5b9dc | ||
|
|
fb192b989d | ||
|
|
d35adc5868 | ||
|
|
c0bf4f58ad | ||
|
|
f24a81fdaf | ||
|
|
40ff0e867c | ||
|
|
a231850911 | ||
|
|
1b2283b173 | ||
|
|
729088fd85 | ||
|
|
88d75a41ae | ||
|
|
237b44ca66 | ||
|
|
6fef30d9b3 |
35
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
35
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Bug Report
|
||||
description: File a bug report.
|
||||
title: "Bug: "
|
||||
labels:
|
||||
- bug / fix
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report! If this bug occurred during local generation check your
|
||||
Archipelago install for a log (probably `C:\ProgramData\Archipelago\logs`)
|
||||
and upload it with this report, as well as all yaml files used.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-results
|
||||
attributes:
|
||||
label: What were the expected results?
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Software
|
||||
description: Where did this bug occur?
|
||||
options:
|
||||
- Website
|
||||
- Local generation
|
||||
- While playing
|
||||
validations:
|
||||
required: true
|
||||
17
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
17
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Feature Request
|
||||
description: Request a feature!
|
||||
title: "Category: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please replace `Category` in the title with what this feature will be targeting, such as Core generation,
|
||||
website, documentation, or a game.
|
||||
Note: this is not for requesting new games to be added. If you would like to request a game, the best place to
|
||||
ask is about it is in the [discord](https://archipelago.gg/discord).
|
||||
- type: textarea
|
||||
id: feature
|
||||
attributes:
|
||||
label: What feature would you like to see?
|
||||
10
.github/ISSUE_TEMPLATE/task.yaml
vendored
Normal file
10
.github/ISSUE_TEMPLATE/task.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
name: Task
|
||||
description: Submit a task to be done. If this is not targeting core, it should likely be elsewhere.
|
||||
title: "Core: "
|
||||
labels:
|
||||
- core
|
||||
- enhancement
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What task needs to be completed?
|
||||
12
.github/pull_request_template.md
vendored
Normal file
12
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
Please format your title with what portion of the project this pull request is
|
||||
targeting and what it's changing.
|
||||
|
||||
ex. "MyGame4: implement new game" or "Docs: add new guide for customizing MyGame3"
|
||||
|
||||
## What is this fixing or adding?
|
||||
|
||||
|
||||
## How was this tested?
|
||||
|
||||
|
||||
## If this makes graphical changes, please attach screenshots.
|
||||
80
.github/workflows/analyze-modified-files.yml
vendored
Normal file
80
.github/workflows/analyze-modified-files.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
name: Analyze modified files
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.py"
|
||||
push:
|
||||
paths:
|
||||
- "**.py"
|
||||
|
||||
env:
|
||||
BASE: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD: ${{ github.event.pull_request.head.sha }}
|
||||
BEFORE: ${{ github.event.before }}
|
||||
AFTER: ${{ github.event.after }}
|
||||
|
||||
jobs:
|
||||
flake8-or-mypy:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
task: [flake8, mypy]
|
||||
|
||||
name: ${{ matrix.task }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: "Determine modified files (pull_request)"
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
git fetch origin $BASE $HEAD
|
||||
DIFF=$(git diff --diff-filter=d --name-only $BASE...$HEAD -- "*.py")
|
||||
echo "modified files:"
|
||||
echo "$DIFF"
|
||||
echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV
|
||||
|
||||
- name: "Determine modified files (push)"
|
||||
if: github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000'
|
||||
run: |
|
||||
git fetch origin $BEFORE $AFTER
|
||||
DIFF=$(git diff --diff-filter=d --name-only $BEFORE..$AFTER -- "*.py")
|
||||
echo "modified files:"
|
||||
echo "$DIFF"
|
||||
echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV
|
||||
|
||||
- name: "Treat all files as modified (new branch)"
|
||||
if: github.event_name == 'push' && github.event.before == '0000000000000000000000000000000000000000'
|
||||
run: |
|
||||
echo "diff=." >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
if: env.diff != ''
|
||||
with:
|
||||
python-version: 3.8
|
||||
|
||||
- name: "Install dependencies"
|
||||
if: env.diff != ''
|
||||
run: |
|
||||
python -m pip install --upgrade pip ${{ matrix.task }}
|
||||
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
||||
|
||||
- name: "flake8: Stop the build if there are Python syntax errors or undefined names"
|
||||
continue-on-error: false
|
||||
if: env.diff != '' && matrix.task == 'flake8'
|
||||
run: |
|
||||
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
|
||||
|
||||
- name: "flake8: Lint modified files"
|
||||
continue-on-error: true
|
||||
if: env.diff != '' && matrix.task == 'flake8'
|
||||
run: |
|
||||
flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
|
||||
|
||||
- name: "mypy: Type check modified files"
|
||||
continue-on-error: true
|
||||
if: env.diff != '' && matrix.task == 'mypy'
|
||||
run: |
|
||||
mypy --follow-imports=silent --install-types --non-interactive --strict ${{ env.diff }}
|
||||
112
.github/workflows/build.yml
vendored
Normal file
112
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
# This workflow will build a release-like distribution when manually dispatched
|
||||
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/workflows/build.yml'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build.yml'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
ENEMIZER_VERSION: 7.1
|
||||
APPIMAGETOOL_VERSION: 13
|
||||
|
||||
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@v3
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.8'
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||
- name: Build
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python setup.py build_exe --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@v3
|
||||
with:
|
||||
name: ${{ env.ZIP_NAME }}
|
||||
path: dist/${{ env.ZIP_NAME }}
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
|
||||
build-ubuntu2004:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
# - copy code below to release.yml -
|
||||
- uses: actions/checkout@v3
|
||||
- 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@v4
|
||||
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/$APPIMAGETOOL_VERSION/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/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/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
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
||||
python setup.py build_exe --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
|
||||
# - copy code above to release.yml -
|
||||
- name: Build Again
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
python setup.py build_exe --yes
|
||||
- name: Store AppImage
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ env.APPIMAGE_NAME }}
|
||||
path: dist/${{ env.APPIMAGE_NAME }}
|
||||
retention-days: 7
|
||||
- name: Store .tar.gz
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ env.TAR_NAME }}
|
||||
path: dist/${{ env.TAR_NAME }}
|
||||
retention-days: 7
|
||||
16
.github/workflows/codeql-analysis.yml
vendored
16
.github/workflows/codeql-analysis.yml
vendored
@@ -14,9 +14,17 @@ name: "CodeQL"
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '**.py'
|
||||
- '**.js'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '**.py'
|
||||
- '**.js'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
schedule:
|
||||
- cron: '44 8 * * 1'
|
||||
|
||||
@@ -35,11 +43,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -50,7 +58,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -64,4 +72,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
29
.github/workflows/lint.yml
vendored
29
.github/workflows/lint.yml
vendored
@@ -1,29 +0,0 @@
|
||||
# This workflow will install Python dependencies, run tests and lint with a single version of Python
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
|
||||
|
||||
name: lint
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.8
|
||||
- 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
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
85
.github/workflows/release.yml
vendored
Normal file
85
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
# This workflow will create a release and store builds to it when an x.y.z tag is pushed
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
|
||||
env:
|
||||
ENEMIZER_VERSION: 7.1
|
||||
APPIMAGETOOL_VERSION: 13
|
||||
|
||||
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-ubuntu2004:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
# - code below copied from build.yml -
|
||||
- uses: actions/checkout@v3
|
||||
- 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@v4
|
||||
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/$APPIMAGETOOL_VERSION/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/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/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
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
||||
python setup.py build_exe --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 }}
|
||||
52
.github/workflows/unittests.yml
vendored
52
.github/workflows/unittests.yml
vendored
@@ -3,24 +3,58 @@
|
||||
|
||||
name: unittests
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**'
|
||||
- '!docs/**'
|
||||
- '!setup.py'
|
||||
- '!*.iss'
|
||||
- '!.gitignore'
|
||||
- '!.github/workflows/**'
|
||||
- '.github/workflows/unittests.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**'
|
||||
- '!docs/**'
|
||||
- '!setup.py'
|
||||
- '!*.iss'
|
||||
- '!.gitignore'
|
||||
- '!.github/workflows/**'
|
||||
- '.github/workflows/unittests.yml'
|
||||
|
||||
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
|
||||
- python: {version: '3.10'} # current
|
||||
os: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v1
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python.version }}
|
||||
uses: actions/setup-python@v4
|
||||
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
|
||||
pip install pytest pytest-subtests
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
- name: Unittests
|
||||
run: |
|
||||
pytest test
|
||||
pytest
|
||||
|
||||
164
.gitignore
vendored
164
.gitignore
vendored
@@ -4,30 +4,41 @@
|
||||
*_Spoiler.txt
|
||||
*.bmbp
|
||||
*.apbp
|
||||
*.apl2ac
|
||||
*.apm3
|
||||
*.apmc
|
||||
*.apz5
|
||||
*.aptloz
|
||||
*.pyc
|
||||
*.pyd
|
||||
*.sfc
|
||||
*.z64
|
||||
*.n64
|
||||
*.nes
|
||||
*.sms
|
||||
*.gb
|
||||
*.gbc
|
||||
*.gba
|
||||
*.wixobj
|
||||
*.lck
|
||||
*.db3
|
||||
*multidata
|
||||
*multisave
|
||||
*.archipelago
|
||||
*.apsave
|
||||
*.BIN
|
||||
|
||||
setups
|
||||
build
|
||||
bundle/components.wxs
|
||||
dist
|
||||
README.html
|
||||
.vs/
|
||||
EnemizerCLI/
|
||||
.mypy_cache/
|
||||
RaceRom.py
|
||||
weights/
|
||||
/MultiMystery/
|
||||
/Players/
|
||||
/QUsb2Snes/
|
||||
/SNI/
|
||||
/options.yaml
|
||||
/config.yaml
|
||||
/uploads/
|
||||
/logs/
|
||||
_persistent_storage.yaml
|
||||
mystery_result_*.yaml
|
||||
@@ -35,4 +46,143 @@ mystery_result_*.yaml
|
||||
success.txt
|
||||
output/
|
||||
Output Logs/
|
||||
/factorio/
|
||||
/factorio/
|
||||
/Minecraft Forge Server/
|
||||
/WebHostLib/static/generated
|
||||
/freeze_requirements.txt
|
||||
/Archipelago.zip
|
||||
/setup.ini
|
||||
/installdelete.iss
|
||||
/data/user.kv
|
||||
/datapackage
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
*.dll
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
installer.log
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# vim editor
|
||||
*.swp
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv*
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.code-workspace
|
||||
shell.nix
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# minecraft server stuff
|
||||
jdk*/
|
||||
minecraft*/
|
||||
minecraft_versions.json
|
||||
!worlds/minecraft/
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
#undertale stuff
|
||||
/Undertale/
|
||||
|
||||
# OS General Files
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Thumbs.db
|
||||
[Dd]esktop.ini
|
||||
|
||||
516
AdventureClient.py
Normal file
516
AdventureClient.py
Normal file
@@ -0,0 +1,516 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import bsdiff4
|
||||
import subprocess
|
||||
import zipfile
|
||||
from asyncio import StreamReader, StreamWriter, CancelledError
|
||||
from typing import List
|
||||
|
||||
|
||||
import Utils
|
||||
from NetUtils import ClientStatus
|
||||
from Utils import async_start
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||
get_base_parser
|
||||
from worlds.adventure import AdventureDeltaPatch
|
||||
|
||||
from worlds.adventure.Locations import base_location_id
|
||||
from worlds.adventure.Rom import AdventureForeignItemInfo, AdventureAutoCollectLocation, BatNoTouchLocation
|
||||
from worlds.adventure.Items import base_adventure_item_id, standard_item_max, item_table
|
||||
from worlds.adventure.Offsets import static_item_element_size, connector_port_offset
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = \
|
||||
"Connection timing out. Please restart your emulator, then restart connector_adventure.lua"
|
||||
CONNECTION_REFUSED_STATUS = \
|
||||
"Connection Refused. Please start your emulator and make sure connector_adventure.lua is running"
|
||||
CONNECTION_RESET_STATUS = \
|
||||
"Connection was reset. Please restart your emulator, then restart connector_adventure.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
SCRIPT_VERSION = 1
|
||||
|
||||
|
||||
class AdventureCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_2600(self):
|
||||
"""Check 2600 Connection State"""
|
||||
if isinstance(self.ctx, AdventureContext):
|
||||
logger.info(f"2600 Status: {self.ctx.atari_status}")
|
||||
|
||||
def _cmd_aconnect(self):
|
||||
"""Discard current atari 2600 connection state"""
|
||||
if isinstance(self.ctx, AdventureContext):
|
||||
self.ctx.atari_sync_task.cancel()
|
||||
|
||||
|
||||
class AdventureContext(CommonContext):
|
||||
command_processor = AdventureCommandProcessor
|
||||
game = 'Adventure'
|
||||
lua_connector_port: int = 17242
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.freeincarnates_used: int = -1
|
||||
self.freeincarnate_pending: int = 0
|
||||
self.foreign_items: [AdventureForeignItemInfo] = []
|
||||
self.autocollect_items: [AdventureAutoCollectLocation] = []
|
||||
self.atari_streams: (StreamReader, StreamWriter) = None
|
||||
self.atari_sync_task = None
|
||||
self.messages = {}
|
||||
self.locations_array = None
|
||||
self.atari_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.display_msgs = True
|
||||
self.deathlink_pending = False
|
||||
self.set_deathlink = False
|
||||
self.client_compatibility_mode = 0
|
||||
self.items_handling = 0b111
|
||||
self.checked_locations_sent: bool = False
|
||||
self.port_offset = 0
|
||||
self.bat_no_touch_locations: [BatNoTouchLocation] = []
|
||||
self.local_item_locations = {}
|
||||
self.dragon_speed_info = {}
|
||||
|
||||
options = Utils.get_options()
|
||||
self.display_msgs = options["adventure_options"]["display_msgs"]
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(AdventureContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.auth = self.player_name
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to adventure_connector to get Player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
if self.display_msgs:
|
||||
self.messages[(time.time(), msg_id)] = msg
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.locations_array = None
|
||||
if Utils.get_options()["adventure_options"].get("death_link", False):
|
||||
self.set_deathlink = True
|
||||
async_start(self.get_freeincarnates_used())
|
||||
elif cmd == "RoomInfo":
|
||||
self.seed_name = args['seed_name']
|
||||
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_names[item.item] for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == "Retrieved":
|
||||
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
|
||||
if self.freeincarnates_used is None:
|
||||
self.freeincarnates_used = 0
|
||||
self.freeincarnates_used += self.freeincarnate_pending
|
||||
self.send_pending_freeincarnates()
|
||||
elif cmd == "SetReply":
|
||||
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
|
||||
self.freeincarnates_used = args["value"]
|
||||
if self.freeincarnates_used is None:
|
||||
self.freeincarnates_used = 0
|
||||
self.freeincarnates_used += self.freeincarnate_pending
|
||||
self.send_pending_freeincarnates()
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
self.deathlink_pending = True
|
||||
super().on_deathlink(data)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class AdventureManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Adventure Client"
|
||||
|
||||
self.ui = AdventureManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
async def get_freeincarnates_used(self):
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "SetNotify", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}])
|
||||
await self.send_msgs([{"cmd": "Get", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}])
|
||||
|
||||
def send_pending_freeincarnates(self):
|
||||
if self.freeincarnate_pending > 0:
|
||||
async_start(self.send_pending_freeincarnates_impl(self.freeincarnate_pending))
|
||||
self.freeincarnate_pending = 0
|
||||
|
||||
async def send_pending_freeincarnates_impl(self, send_val: int) -> None:
|
||||
await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used",
|
||||
"default": 0, "want_reply": False,
|
||||
"operations": [{"operation": "add", "value": send_val}]}])
|
||||
|
||||
async def used_freeincarnate(self) -> None:
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used",
|
||||
"default": 0, "want_reply": True,
|
||||
"operations": [{"operation": "add", "value": 1}]}])
|
||||
else:
|
||||
self.freeincarnate_pending = self.freeincarnate_pending + 1
|
||||
|
||||
|
||||
def convert_item_id(ap_item_id: int):
|
||||
static_item_index = ap_item_id - base_adventure_item_id
|
||||
return static_item_index * static_item_element_size
|
||||
|
||||
|
||||
def get_payload(ctx: AdventureContext):
|
||||
current_time = time.time()
|
||||
items = []
|
||||
dragon_speed_update = {}
|
||||
diff_a_locked = ctx.diff_a_mode > 0
|
||||
diff_b_locked = ctx.diff_b_mode > 0
|
||||
freeincarnate_count = 0
|
||||
for item in ctx.items_received:
|
||||
item_id_str = str(item.item)
|
||||
if base_adventure_item_id < item.item <= standard_item_max:
|
||||
items.append(convert_item_id(item.item))
|
||||
elif item_id_str in ctx.dragon_speed_info:
|
||||
if item.item in dragon_speed_update:
|
||||
last_index = len(ctx.dragon_speed_info[item_id_str]) - 1
|
||||
dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][last_index]
|
||||
else:
|
||||
dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][0]
|
||||
elif item.item == item_table["Left Difficulty Switch"].id:
|
||||
diff_a_locked = False
|
||||
elif item.item == item_table["Right Difficulty Switch"].id:
|
||||
diff_b_locked = False
|
||||
elif item.item == item_table["Freeincarnate"].id:
|
||||
freeincarnate_count = freeincarnate_count + 1
|
||||
freeincarnates_available = 0
|
||||
|
||||
if ctx.freeincarnates_used >= 0:
|
||||
freeincarnates_available = freeincarnate_count - (ctx.freeincarnates_used + ctx.freeincarnate_pending)
|
||||
ret = json.dumps(
|
||||
{
|
||||
"items": items,
|
||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
||||
if key[0] > current_time - 10},
|
||||
"deathlink": ctx.deathlink_pending,
|
||||
"dragon_speeds": dragon_speed_update,
|
||||
"difficulty_a_locked": diff_a_locked,
|
||||
"difficulty_b_locked": diff_b_locked,
|
||||
"freeincarnates_available": freeincarnates_available,
|
||||
"bat_logic": ctx.bat_logic
|
||||
}
|
||||
)
|
||||
ctx.deathlink_pending = False
|
||||
return ret
|
||||
|
||||
|
||||
async def parse_locations(data: List, ctx: AdventureContext):
|
||||
locations = data
|
||||
|
||||
# for loc_name, loc_data in location_table.items():
|
||||
|
||||
# if flags["EventFlag"][280] & 1 and not ctx.finished_game:
|
||||
# await ctx.send_msgs([
|
||||
# {"cmd": "StatusUpdate",
|
||||
# "status": 30}
|
||||
# ])
|
||||
# ctx.finished_game = True
|
||||
if locations == ctx.locations_array:
|
||||
return
|
||||
ctx.locations_array = locations
|
||||
if locations is not None:
|
||||
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
|
||||
|
||||
|
||||
def send_ap_foreign_items(adventure_context):
|
||||
foreign_item_json_list = []
|
||||
autocollect_item_json_list = []
|
||||
bat_no_touch_locations_json_list = []
|
||||
for fi in adventure_context.foreign_items:
|
||||
foreign_item_json_list.append(fi.get_dict())
|
||||
for fi in adventure_context.autocollect_items:
|
||||
autocollect_item_json_list.append(fi.get_dict())
|
||||
for ntl in adventure_context.bat_no_touch_locations:
|
||||
bat_no_touch_locations_json_list.append(ntl.get_dict())
|
||||
payload = json.dumps(
|
||||
{
|
||||
"foreign_items": foreign_item_json_list,
|
||||
"autocollect_items": autocollect_item_json_list,
|
||||
"local_item_locations": adventure_context.local_item_locations,
|
||||
"bat_no_touch_locations": bat_no_touch_locations_json_list
|
||||
}
|
||||
)
|
||||
print("sending foreign items")
|
||||
msg = payload.encode()
|
||||
(reader, writer) = adventure_context.atari_streams
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
|
||||
|
||||
def send_checked_locations_if_needed(adventure_context):
|
||||
if not adventure_context.checked_locations_sent and adventure_context.checked_locations is not None:
|
||||
if len(adventure_context.checked_locations) == 0:
|
||||
return
|
||||
checked_short_ids = []
|
||||
for location in adventure_context.checked_locations:
|
||||
checked_short_ids.append(location - base_location_id)
|
||||
print("Sending checked locations")
|
||||
payload = json.dumps(
|
||||
{
|
||||
"checked_locations": checked_short_ids,
|
||||
}
|
||||
)
|
||||
msg = payload.encode()
|
||||
(reader, writer) = adventure_context.atari_streams
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
adventure_context.checked_locations_sent = True
|
||||
|
||||
|
||||
async def atari_sync_task(ctx: AdventureContext):
|
||||
logger.info("Starting Atari 2600 connector. Use /2600 for status information")
|
||||
while not ctx.exit_event.is_set():
|
||||
try:
|
||||
error_status = None
|
||||
if ctx.atari_streams:
|
||||
(reader, writer) = ctx.atari_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 1+ fields
|
||||
# 1. A keepalive response of the Players Name (always)
|
||||
# 2. romhash field with sha256 hash of the ROM memory region
|
||||
# 3. locations, messages, and deathLink
|
||||
# 4. freeincarnate, to indicate a freeincarnate was used
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
||||
data_decoded = json.loads(data.decode())
|
||||
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
|
||||
msg = "You are connecting with an incompatible Lua script version. Ensure your connector " \
|
||||
"Lua and AdventureClient are from the same Archipelago installation."
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
if ctx.seed_name and bytes(ctx.seed_name, encoding='ASCII') != ctx.seed_name_from_data:
|
||||
msg = "The server is running a different multiworld than your client is. " \
|
||||
"(invalid seed_name)"
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
if 'romhash' in data_decoded:
|
||||
if ctx.rom_hash.upper() != data_decoded['romhash'].upper():
|
||||
msg = "The rom hash does not match the client rom hash data"
|
||||
print("got " + data_decoded['romhash'])
|
||||
print("expected " + str(ctx.rom_hash))
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
if ctx.auth is None:
|
||||
ctx.auth = ctx.player_name
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
if 'locations' in data_decoded and ctx.game and ctx.atari_status == CONNECTION_CONNECTED_STATUS \
|
||||
and not error_status and ctx.auth:
|
||||
# Not just a keep alive ping, parse
|
||||
async_start(parse_locations(data_decoded['locations'], ctx))
|
||||
if 'deathLink' in data_decoded and data_decoded['deathLink'] > 0 and 'DeathLink' in ctx.tags:
|
||||
dragon_name = "a dragon"
|
||||
if data_decoded['deathLink'] == 1:
|
||||
dragon_name = "Rhindle"
|
||||
elif data_decoded['deathLink'] == 2:
|
||||
dragon_name = "Yorgle"
|
||||
elif data_decoded['deathLink'] == 3:
|
||||
dragon_name = "Grundle"
|
||||
print (ctx.auth + " has been eaten by " + dragon_name )
|
||||
await ctx.send_death(ctx.auth + " has been eaten by " + dragon_name)
|
||||
# TODO - also if player reincarnates with a dragon onscreen ' dies to avoid being eaten by '
|
||||
if 'victory' in data_decoded and not ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
if 'freeincarnate' in data_decoded:
|
||||
await ctx.used_freeincarnate()
|
||||
if ctx.set_deathlink:
|
||||
await ctx.update_death_link(True)
|
||||
send_checked_locations_if_needed(ctx)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
except CancelledError:
|
||||
logger.debug("Connection Cancelled, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
pass
|
||||
except Exception as e:
|
||||
print("unknown exception " + e)
|
||||
raise
|
||||
if ctx.atari_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to 2600")
|
||||
ctx.atari_status = CONNECTION_CONNECTED_STATUS
|
||||
ctx.checked_locations_sent = False
|
||||
send_ap_foreign_items(ctx)
|
||||
send_checked_locations_if_needed(ctx)
|
||||
else:
|
||||
ctx.atari_status = f"Was tentatively connected but error occurred: {error_status}"
|
||||
elif error_status:
|
||||
ctx.atari_status = error_status
|
||||
logger.info("Lost connection to 2600 and attempting to reconnect. Use /2600 for status updates")
|
||||
else:
|
||||
try:
|
||||
port = ctx.lua_connector_port + ctx.port_offset
|
||||
logger.debug(f"Attempting to connect to 2600 on port {port}")
|
||||
print(f"Attempting to connect to 2600 on port {port}")
|
||||
ctx.atari_streams = await asyncio.wait_for(
|
||||
asyncio.open_connection("localhost",
|
||||
port),
|
||||
timeout=10)
|
||||
ctx.atari_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.atari_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
except CancelledError:
|
||||
pass
|
||||
except CancelledError:
|
||||
pass
|
||||
print("exiting atari sync task")
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
|
||||
rom_args = Utils.get_options()["adventure_options"].get("rom_args")
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif os.path.isfile(auto_start):
|
||||
open_args = [auto_start, romfile]
|
||||
if rom_args is not None:
|
||||
open_args.insert(1, rom_args)
|
||||
subprocess.Popen(open_args,
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def patch_and_run_game(patch_file, ctx):
|
||||
base_name = os.path.splitext(patch_file)[0]
|
||||
comp_path = base_name + '.a26'
|
||||
try:
|
||||
base_rom = AdventureDeltaPatch.get_source_data()
|
||||
except Exception as msg:
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
|
||||
with open(Utils.local_path("data", "adventure_basepatch.bsdiff4"), "rb") as file:
|
||||
basepatch = bytes(file.read())
|
||||
|
||||
base_patched_rom_data = bsdiff4.patch(base_rom, basepatch)
|
||||
|
||||
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
|
||||
if not AdventureDeltaPatch.check_version(patch_archive):
|
||||
logger.error("apadvn version doesn't match this client. Make sure your generator and client are the same")
|
||||
raise Exception("apadvn version doesn't match this client.")
|
||||
|
||||
ctx.foreign_items = AdventureDeltaPatch.read_foreign_items(patch_archive)
|
||||
ctx.autocollect_items = AdventureDeltaPatch.read_autocollect_items(patch_archive)
|
||||
ctx.local_item_locations = AdventureDeltaPatch.read_local_item_locations(patch_archive)
|
||||
ctx.dragon_speed_info = AdventureDeltaPatch.read_dragon_speed_info(patch_archive)
|
||||
ctx.seed_name_from_data, ctx.player_name = AdventureDeltaPatch.read_rom_info(patch_archive)
|
||||
ctx.diff_a_mode, ctx.diff_b_mode = AdventureDeltaPatch.read_difficulty_switch_info(patch_archive)
|
||||
ctx.bat_logic = AdventureDeltaPatch.read_bat_logic(patch_archive)
|
||||
ctx.bat_no_touch_locations = AdventureDeltaPatch.read_bat_no_touch(patch_archive)
|
||||
ctx.rom_deltas = AdventureDeltaPatch.read_rom_deltas(patch_archive)
|
||||
ctx.auth = ctx.player_name
|
||||
|
||||
patched_rom_data = AdventureDeltaPatch.apply_rom_deltas(base_patched_rom_data, ctx.rom_deltas)
|
||||
rom_hash = hashlib.sha256()
|
||||
rom_hash.update(patched_rom_data)
|
||||
ctx.rom_hash = rom_hash.hexdigest()
|
||||
ctx.port_offset = patched_rom_data[connector_port_offset]
|
||||
|
||||
with open(comp_path, "wb") as patched_rom_file:
|
||||
patched_rom_file.write(patched_rom_data)
|
||||
|
||||
async_start(run_game(comp_path))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Utils.init_logging("AdventureClient")
|
||||
|
||||
async def main():
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('patch_file', default="", type=str, nargs="?",
|
||||
help='Path to an ADVNTURE.BIN rom file')
|
||||
parser.add_argument('port', default=17242, type=int, nargs="?",
|
||||
help='port for adventure_connector connection')
|
||||
args = parser.parse_args()
|
||||
|
||||
ctx = AdventureContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
ctx.atari_sync_task = asyncio.create_task(atari_sync_task(ctx), name="Adventure Sync")
|
||||
|
||||
if args.patch_file:
|
||||
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
|
||||
if ext == "apadvn":
|
||||
logger.info("apadvn file supplied, beginning patching process...")
|
||||
async_start(patch_and_run_game(args.patch_file, ctx))
|
||||
else:
|
||||
logger.warning(f"Unknown patch file extension {ext}")
|
||||
if args.port is int:
|
||||
ctx.lua_connector_port = args.port
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.atari_sync_task:
|
||||
await ctx.atari_sync_task
|
||||
print("finished atari_sync_task (main)")
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
1955
BaseClasses.py
1955
BaseClasses.py
File diff suppressed because it is too large
Load Diff
172
ChecksFinderClient.py
Normal file
172
ChecksFinderClient.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import shutil
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("ChecksFinderClient", exception_logger="Client")
|
||||
|
||||
from NetUtils import NetworkItem, ClientStatus
|
||||
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
||||
CommonContext, server_loop
|
||||
|
||||
|
||||
class ChecksFinderClientCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.output(f"Syncing items.")
|
||||
self.ctx.syncing = True
|
||||
|
||||
|
||||
class ChecksFinderContext(CommonContext):
|
||||
command_processor: int = ChecksFinderClientCommandProcessor
|
||||
game = "ChecksFinder"
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(ChecksFinderContext, self).__init__(server_address, password)
|
||||
self.send_index: int = 0
|
||||
self.syncing = False
|
||||
self.awaiting_bridge = False
|
||||
# self.game_communication_path: files go in this path to pass data between us and the actual game
|
||||
if "localappdata" in os.environ:
|
||||
self.game_communication_path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
||||
else:
|
||||
# not windows. game is an exe so let's see if wine might be around to run it
|
||||
if "WINEPREFIX" in os.environ:
|
||||
wineprefix = os.environ["WINEPREFIX"]
|
||||
elif shutil.which("wine") or shutil.which("wine-stable"):
|
||||
wineprefix = os.path.expanduser("~/.wine") # default root of wine system data, deep in which is app data
|
||||
else:
|
||||
msg = "ChecksFinderClient couldn't detect system type. Unable to infer required game_communication_path"
|
||||
logger.error("Error: " + msg)
|
||||
Utils.messagebox("Error", msg, error=True)
|
||||
sys.exit(1)
|
||||
self.game_communication_path = os.path.join(
|
||||
wineprefix,
|
||||
"drive_c",
|
||||
os.path.expandvars("users/$USER/Local Settings/Application Data/ChecksFinder"))
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(ChecksFinderContext, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
async def connection_closed(self):
|
||||
await super(ChecksFinderContext, self).connection_closed()
|
||||
for root, dirs, files in os.walk(self.game_communication_path):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root + "/" + file)
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
if self.server:
|
||||
return [self.server]
|
||||
else:
|
||||
return []
|
||||
|
||||
async def shutdown(self):
|
||||
await super(ChecksFinderContext, self).shutdown()
|
||||
for root, dirs, files in os.walk(self.game_communication_path):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root+"/"+file)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
if not os.path.exists(self.game_communication_path):
|
||||
os.makedirs(self.game_communication_path)
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.close()
|
||||
if cmd in {"ReceivedItems"}:
|
||||
start_index = args["index"]
|
||||
if start_index != len(self.items_received):
|
||||
for item in args['items']:
|
||||
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.write(str(NetworkItem(*item).item))
|
||||
f.close()
|
||||
|
||||
if cmd in {"RoomUpdate"}:
|
||||
if "checked_locations" in args:
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.close()
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager
|
||||
|
||||
class ChecksFinderManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago ChecksFinder Client"
|
||||
|
||||
self.ui = ChecksFinderManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
async def game_watcher(ctx: ChecksFinderContext):
|
||||
from worlds.checksfinder.Locations import lookup_id_to_name
|
||||
while not ctx.exit_event.is_set():
|
||||
if ctx.syncing == True:
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
ctx.syncing = False
|
||||
sending = []
|
||||
victory = False
|
||||
for root, dirs, files in os.walk(ctx.game_communication_path):
|
||||
for file in files:
|
||||
if file.find("send") > -1:
|
||||
st = file.split("send", -1)[1]
|
||||
sending = sending+[(int(st))]
|
||||
if file.find("victory") > -1:
|
||||
victory = True
|
||||
ctx.locations_checked = sending
|
||||
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
||||
await ctx.send_msgs(message)
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
async def main(args):
|
||||
ctx = ChecksFinderContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
progression_watcher = asyncio.create_task(
|
||||
game_watcher(ctx), name="ChecksFinderProgressionWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await progression_watcher
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="ChecksFinder Client, for text interfacing.")
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
847
CommonClient.py
847
CommonClient.py
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
267
FF1Client.py
Normal file
267
FF1Client.py
Normal file
@@ -0,0 +1,267 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
from typing import List
|
||||
|
||||
|
||||
import Utils
|
||||
from Utils import async_start
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||
get_base_parser
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
DISPLAY_MSGS = True
|
||||
|
||||
|
||||
class FF1CommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_nes(self):
|
||||
"""Check NES Connection State"""
|
||||
if isinstance(self.ctx, FF1Context):
|
||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
||||
|
||||
def _cmd_toggle_msgs(self):
|
||||
"""Toggle displaying messages in EmuHawk"""
|
||||
global DISPLAY_MSGS
|
||||
DISPLAY_MSGS = not DISPLAY_MSGS
|
||||
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
||||
|
||||
|
||||
class FF1Context(CommonContext):
|
||||
command_processor = FF1CommandProcessor
|
||||
game = 'Final Fantasy'
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.nes_streams: (StreamReader, StreamWriter) = None
|
||||
self.nes_sync_task = None
|
||||
self.messages = {}
|
||||
self.locations_array = None
|
||||
self.nes_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.display_msgs = True
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(FF1Context, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to NES to get Player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
if DISPLAY_MSGS:
|
||||
self.messages[time.time(), msg_id] = msg
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
async_start(parse_locations(self.locations_array, self, True))
|
||||
elif cmd == 'Print':
|
||||
msg = args['text']
|
||||
if ': !' not in msg:
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if self.ui:
|
||||
self.ui.print_json(copy.deepcopy(args["data"]))
|
||||
else:
|
||||
text = self.jsontotextparser(copy.deepcopy(args["data"]))
|
||||
logger.info(text)
|
||||
relevant = args.get("type", None) in {"Hint", "ItemSend"}
|
||||
if relevant:
|
||||
item = args["item"]
|
||||
# goes to this world
|
||||
if self.slot_concerns_self(args["receiving"]):
|
||||
relevant = True
|
||||
# found in this world
|
||||
elif self.slot_concerns_self(item.player):
|
||||
relevant = True
|
||||
# not related
|
||||
else:
|
||||
relevant = False
|
||||
if relevant:
|
||||
item = args["item"]
|
||||
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
|
||||
self._set_message(msg, item.item)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class FF1Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Final Fantasy 1 Client"
|
||||
|
||||
self.ui = FF1Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
def get_payload(ctx: FF1Context):
|
||||
current_time = time.time()
|
||||
return json.dumps(
|
||||
{
|
||||
"items": [item.item for item in ctx.items_received],
|
||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
||||
if key[0] > current_time - 10}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
|
||||
if locations_array == ctx.locations_array and not force:
|
||||
return
|
||||
else:
|
||||
# print("New values")
|
||||
ctx.locations_array = locations_array
|
||||
locations_checked = []
|
||||
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "StatusUpdate",
|
||||
"status": 30}
|
||||
])
|
||||
ctx.finished_game = True
|
||||
for location in ctx.missing_locations:
|
||||
# index will be - 0x100 or 0x200
|
||||
index = location
|
||||
if location < 0x200:
|
||||
# Location is a chest
|
||||
index -= 0x100
|
||||
flag = 0x04
|
||||
else:
|
||||
# Location is an NPC
|
||||
index -= 0x200
|
||||
flag = 0x02
|
||||
|
||||
# print(f"Location: {ctx.location_names[location]}")
|
||||
# print(f"Index: {str(hex(index))}")
|
||||
# print(f"value: {locations_array[index] & flag != 0}")
|
||||
if locations_array[index] & flag != 0:
|
||||
locations_checked.append(location)
|
||||
if locations_checked:
|
||||
# print([ctx.location_names[location] for location in locations_checked])
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "LocationChecks",
|
||||
"locations": locations_checked}
|
||||
])
|
||||
|
||||
|
||||
async def nes_sync_task(ctx: FF1Context):
|
||||
logger.info("Starting nes connector. Use /nes for status information")
|
||||
while not ctx.exit_event.is_set():
|
||||
error_status = None
|
||||
if ctx.nes_streams:
|
||||
(reader, writer) = ctx.nes_streams
|
||||
msg = get_payload(ctx).encode()
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to two fields:
|
||||
# 1. A keepalive response of the Players Name (always)
|
||||
# 2. An array representing the memory values of the locations area (if in game)
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
||||
data_decoded = json.loads(data.decode())
|
||||
# print(data_decoded)
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
async_start(parse_locations(data_decoded['locations'], ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||
if ctx.auth == '':
|
||||
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
|
||||
"the ROM using the same link but adding your slot name")
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to NES")
|
||||
ctx.nes_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
|
||||
elif error_status:
|
||||
ctx.nes_status = error_status
|
||||
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to NES")
|
||||
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
|
||||
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
Utils.init_logging("FF1Client")
|
||||
|
||||
options = Utils.get_options()
|
||||
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
|
||||
|
||||
async def main(args):
|
||||
ctx = FF1Context(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.nes_sync_task:
|
||||
await ctx.nes_sync_task
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser()
|
||||
args = parser.parse_args()
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
@@ -1,231 +1,12 @@
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import string
|
||||
import copy
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from __future__ import annotations
|
||||
|
||||
import colorama
|
||||
import asyncio
|
||||
from queue import Queue, Empty
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger
|
||||
from MultiServer import mark_raw
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds.factorio.Client import check_stdin, launch
|
||||
import Utils
|
||||
import random
|
||||
from NetUtils import RawJSONtoTextParser, NetworkItem, ClientStatus
|
||||
|
||||
from worlds.factorio.Technologies import lookup_id_to_name
|
||||
|
||||
rcon_port = 24242
|
||||
rcon_password = ''.join(random.choice(string.ascii_letters) for x in range(32))
|
||||
save_name = "Archipelago"
|
||||
|
||||
server_args = (save_name, "--rcon-port", rcon_port, "--rcon-password", rcon_password)
|
||||
|
||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
|
||||
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)
|
||||
|
||||
threadpool = ThreadPoolExecutor(10)
|
||||
|
||||
|
||||
class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
@mark_raw
|
||||
def _cmd_factorio(self, text: str) -> bool:
|
||||
"""Send the following command to the bound Factorio Server."""
|
||||
if self.ctx.rcon_client:
|
||||
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:
|
||||
self.output("Cannot connect to a server with unknown own identity, bridge to Factorio first.")
|
||||
return super(FactorioCommandProcessor, self)._cmd_connect(address)
|
||||
|
||||
|
||||
class FactorioContext(CommonContext):
|
||||
command_processor = FactorioCommandProcessor
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FactorioContext, self).__init__(*args, **kwargs)
|
||||
self.send_index = 0
|
||||
self.rcon_client = None
|
||||
self.raw_json_text_parser = RawJSONtoTextParser(self)
|
||||
|
||||
async def server_auth(self, password_requested):
|
||||
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"
|
||||
}])
|
||||
|
||||
def on_print(self, args: dict):
|
||||
logger.info(args["text"])
|
||||
if self.rcon_client:
|
||||
cleaned_text = args['text'].replace('"', '')
|
||||
self.rcon_client.send_command(f"/sc game.print(\"Archipelago: {cleaned_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.
|
||||
copy_data = copy.deepcopy(args["data"]) # jsontotextparser is destructive currently
|
||||
logger.info(self.jsontotextparser(args["data"]))
|
||||
if self.rcon_client:
|
||||
cleaned_text = self.raw_json_text_parser(copy_data).replace('"', '')
|
||||
self.rcon_client.send_command(f"/sc game.print(\"Archipelago: {cleaned_text}\")")
|
||||
|
||||
async def game_watcher(ctx: FactorioContext, bridge_file: str):
|
||||
bridge_logger = logging.getLogger("FactorioWatcher")
|
||||
from worlds.factorio.Technologies import lookup_id_to_name
|
||||
bridge_counter = 0
|
||||
try:
|
||||
while 1:
|
||||
if os.path.exists(bridge_file):
|
||||
bridge_logger.info("Found Factorio Bridge file.")
|
||||
while 1:
|
||||
with open(bridge_file) as f:
|
||||
data = json.load(f)
|
||||
research_data = data["research_done"]
|
||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||
victory = data["victory"]
|
||||
ctx.auth = data["slot_name"]
|
||||
|
||||
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(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)
|
||||
else:
|
||||
bridge_counter += 1
|
||||
if bridge_counter >= 60:
|
||||
bridge_logger.info(
|
||||
"Did not find Factorio Bridge file, "
|
||||
"waiting for mod to run, which requires the server to run, "
|
||||
"which requires a player to be connected.")
|
||||
bridge_counter = 0
|
||||
await asyncio.sleep(1)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.error("Aborted Factorio Server Bridge")
|
||||
|
||||
|
||||
def stream_factorio_output(pipe, queue):
|
||||
def queuer():
|
||||
while 1:
|
||||
text = pipe.readline().strip()
|
||||
if text:
|
||||
queue.put_nowait(text)
|
||||
|
||||
from threading import Thread
|
||||
|
||||
thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
||||
async def factorio_server_watcher(ctx: FactorioContext):
|
||||
import subprocess
|
||||
import factorio_rcon
|
||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||
factorio_process = subprocess.Popen((executable, "--start-server", *(str(elem) for elem in server_args)),
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
encoding="utf-8")
|
||||
factorio_server_logger.info("Started Factorio Server")
|
||||
factorio_queue = Queue()
|
||||
stream_factorio_output(factorio_process.stdout, factorio_queue)
|
||||
stream_factorio_output(factorio_process.stderr, factorio_queue)
|
||||
script_folder = None
|
||||
try:
|
||||
while 1:
|
||||
while not factorio_queue.empty():
|
||||
msg = factorio_queue.get()
|
||||
factorio_server_logger.info(msg)
|
||||
if not ctx.rcon_client and "Hosting game 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')")
|
||||
ctx.rcon_client.send_command("/ap-sync")
|
||||
if not script_folder and "Write data path:" in msg:
|
||||
script_folder = msg.split("Write data path: ", 1)[1].split("[", 1)[0].strip()
|
||||
bridge_file = os.path.join(script_folder, "script-output", "ap_bridge.json")
|
||||
if os.path.exists(bridge_file):
|
||||
os.remove(bridge_file)
|
||||
logging.info(f"Bridge File Path: {bridge_file}")
|
||||
asyncio.create_task(game_watcher(ctx, bridge_file), name="FactorioProgressionWatcher")
|
||||
if ctx.rcon_client:
|
||||
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}")
|
||||
else:
|
||||
item_name = lookup_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} {player_name}')
|
||||
ctx.send_index += 1
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.error("Aborted Factorio Server Bridge")
|
||||
|
||||
|
||||
async def main():
|
||||
ctx = FactorioContext(None, None, True)
|
||||
# testing shortcuts
|
||||
# ctx.server_address = "localhost"
|
||||
# ctx.auth = "Nauvis"
|
||||
if ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
await asyncio.sleep(3)
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
ctx.snes_reconnect_address = None
|
||||
|
||||
await asyncio.gather(input_task, factorio_server_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
|
||||
await factorio_server_task
|
||||
|
||||
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()
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||
check_stdin()
|
||||
launch()
|
||||
|
||||
648
Generate.py
Normal file
648
Generate.py
Normal file
@@ -0,0 +1,648 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import ChainMap, Counter
|
||||
from typing import Any, Callable, Dict, Tuple, Union
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
from worlds.alttp import Options as LttPOptions
|
||||
from worlds.generic import PlandoConnection
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, user_path
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
import Options
|
||||
from worlds.alttp.Text import TextTable
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import copy
|
||||
|
||||
|
||||
def mystery_argparse():
|
||||
options = get_options()
|
||||
defaults = options["generator"]
|
||||
|
||||
def resolve_path(path: str, resolver: Callable[[str], str]) -> str:
|
||||
return path if os.path.isabs(path) else resolver(path)
|
||||
|
||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||
parser.add_argument('--weights_file_path', default=defaults["weights_file_path"],
|
||||
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('--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"')
|
||||
parser.add_argument("--skip_prog_balancing", action="store_true",
|
||||
help="Skip progression balancing step during generation.")
|
||||
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: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||
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)
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||
random.seed(seed)
|
||||
seed_name = get_seed_name(random)
|
||||
|
||||
if args.race:
|
||||
logging.info("Race mode enabled. Using non-deterministic random source.")
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
||||
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
||||
try:
|
||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
|
||||
logging.info(f"Weights: {args.weights_file_path} >> "
|
||||
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
|
||||
|
||||
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
||||
try:
|
||||
meta_weights = read_weights_yamls(args.meta_file_path)[-1]
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
|
||||
logging.info(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
||||
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
|
||||
del(meta_weights["meta_description"])
|
||||
except Exception as e:
|
||||
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
|
||||
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 not fname.startswith(".") and \
|
||||
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||
path = os.path.join(args.player_files_path, fname)
|
||||
try:
|
||||
weights_cache[fname] = read_weights_yamls(path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e
|
||||
|
||||
# sort dict for consistent results across platforms:
|
||||
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
||||
for filename, yaml_data in weights_cache.items():
|
||||
if filename not in {args.meta_file_path, args.weights_file_path}:
|
||||
for yaml in yaml_data:
|
||||
logging.info(f"P{player_id} Weights: {filename} >> "
|
||||
f"{get_choice('description', yaml, 'No description specified')}")
|
||||
player_files[player_id] = filename
|
||||
player_id += 1
|
||||
|
||||
args.multi = max(player_id - 1, args.multi)
|
||||
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
|
||||
f"{seed_name} Seed {seed} with plando: {args.plando}")
|
||||
|
||||
if not weights_cache:
|
||||
raise Exception(f"No weights found. "
|
||||
f"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.plando_options = args.plando
|
||||
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
|
||||
erargs.spoiler = args.spoiler
|
||||
erargs.race = args.race
|
||||
erargs.outputname = seed_name
|
||||
erargs.outputpath = args.outputpath
|
||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||
|
||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
|
||||
for fname, yamls in weights_cache.items()}
|
||||
|
||||
if meta_weights:
|
||||
for category_name, category_dict in meta_weights.items():
|
||||
for key in category_dict:
|
||||
option = roll_meta_option(key, category_name, category_dict)
|
||||
if option is not None:
|
||||
for path in weights_cache:
|
||||
for yaml in weights_cache[path]:
|
||||
if category_name is None:
|
||||
for category in yaml:
|
||||
if category in AutoWorldRegister.world_types and key in Options.common_options:
|
||||
yaml[category][key] = option
|
||||
elif category_name not in yaml:
|
||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||
else:
|
||||
yaml[category_name][key] = option
|
||||
|
||||
player_path_cache = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||
name_counter = Counter()
|
||||
erargs.player_settings = {}
|
||||
|
||||
player = 1
|
||||
while player <= args.multi:
|
||||
path = player_path_cache[player]
|
||||
if path:
|
||||
try:
|
||||
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
||||
for settingsObject in settings:
|
||||
for k, v in vars(settingsObject).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(erargs, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(erargs, k, {player: v})
|
||||
except Exception as e:
|
||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||
|
||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||
erargs.name[player] = f"Player{player}"
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
player += 1
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||
else:
|
||||
raise RuntimeError(f'No weights specified for player {player}')
|
||||
|
||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in 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_yamls(path) -> Tuple[Any, ...]:
|
||||
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 tuple(parse_yamls(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) -> 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) -> 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.lower()] += 1
|
||||
number = name_counter[name.lower()]
|
||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
||||
NUMBER=(number if number > 1 else ''),
|
||||
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) -> Union[str, int]:
|
||||
try:
|
||||
return int(input_data)
|
||||
except:
|
||||
return input_data
|
||||
|
||||
|
||||
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: 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_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
||||
if not game:
|
||||
return get_choice(option_key, category_dict)
|
||||
if game in AutoWorldRegister.world_types:
|
||||
game_world = AutoWorldRegister.world_types[game]
|
||||
options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
|
||||
if option_key in options:
|
||||
if options[option_key].supports_weighting:
|
||||
return get_choice(option_key, category_dict)
|
||||
return category_dict[option_key]
|
||||
if game == "A Link to the Past": # TODO wow i hate this
|
||||
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
|
||||
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
|
||||
"triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
|
||||
"boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
|
||||
"red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
|
||||
"misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
|
||||
"random_sprite_on_event"}:
|
||||
return get_choice(option_key, category_dict)
|
||||
raise Exception(f"Error generating meta option {option_key} for {game}.")
|
||||
|
||||
|
||||
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 handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
||||
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:
|
||||
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||
else:
|
||||
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
|
||||
|
||||
|
||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.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 = PlandoOptions.from_option_string(requirements.get("plando", ""))
|
||||
if required_plando_options not in plando_options:
|
||||
if required_plando_options:
|
||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||
f"which is not enabled.")
|
||||
|
||||
ret = argparse.Namespace()
|
||||
for option_key in Options.per_game_common_options:
|
||||
if option_key in weights 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 AutoWorldRegister.world_types:
|
||||
picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0]
|
||||
raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? "
|
||||
f"Check your spelling or installation of that world.")
|
||||
|
||||
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)))
|
||||
|
||||
for option_key, option in world_type.option_definitions.items():
|
||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||
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 option_key not in world_type.option_definitions and \
|
||||
(option_key not in Options.common_options or option_key in game_weights):
|
||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||
if PlandoOptions.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 PlandoOptions.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)
|
||||
|
||||
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]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 PlandoOptions.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 PlandoOptions.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)
|
||||
194
GuiUtils.py
194
GuiUtils.py
@@ -1,194 +0,0 @@
|
||||
import queue
|
||||
import threading
|
||||
import tkinter as tk
|
||||
|
||||
from Utils import local_path
|
||||
|
||||
def set_icon(window):
|
||||
er16 = tk.PhotoImage(file=local_path('data', 'ER16.gif'))
|
||||
er32 = tk.PhotoImage(file=local_path('data', 'ER32.gif'))
|
||||
er48 = tk.PhotoImage(file=local_path('data', 'ER32.gif'))
|
||||
window.tk.call('wm', 'iconphoto', window._w, er16, er32, er48) # pylint: disable=protected-access
|
||||
|
||||
# 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
|
||||
894
KH2Client.py
Normal file
894
KH2Client.py
Normal file
@@ -0,0 +1,894 @@
|
||||
import os
|
||||
import asyncio
|
||||
import ModuleUpdate
|
||||
import json
|
||||
import Utils
|
||||
from pymem import pymem
|
||||
from worlds.kh2.Items import exclusionItem_table, CheckDupingItems
|
||||
from worlds.kh2 import all_locations, item_dictionary_table, exclusion_table
|
||||
|
||||
from worlds.kh2.WorldLocations import *
|
||||
|
||||
from worlds import network_data_package
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("KH2Client", exception_logger="Client")
|
||||
|
||||
from NetUtils import ClientStatus
|
||||
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
||||
CommonContext, server_loop
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
kh2_loc_name_to_id = network_data_package["games"]["Kingdom Hearts 2"]["location_name_to_id"]
|
||||
|
||||
|
||||
# class KH2CommandProcessor(ClientCommandProcessor):
|
||||
|
||||
|
||||
class KH2Context(CommonContext):
|
||||
# command_processor: int = KH2CommandProcessor
|
||||
game = "Kingdom Hearts 2"
|
||||
items_handling = 0b101 # Indicates you get items sent from other worlds.
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(KH2Context, self).__init__(server_address, password)
|
||||
self.kh2LocalItems = None
|
||||
self.ability = None
|
||||
self.growthlevel = None
|
||||
self.KH2_sync_task = None
|
||||
self.syncing = False
|
||||
self.kh2connected = False
|
||||
self.serverconneced = False
|
||||
self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()}
|
||||
self.location_name_to_data = {name: data for name, data, in all_locations.items()}
|
||||
self.lookup_id_to_item: typing.Dict[int, str] = {data.code: item_name for item_name, data in
|
||||
item_dictionary_table.items() if data.code}
|
||||
self.lookup_id_to_Location: typing.Dict[int, str] = {data.code: item_name for item_name, data in
|
||||
all_locations.items() if data.code}
|
||||
self.location_name_to_worlddata = {name: data for name, data, in all_world_locations.items()}
|
||||
|
||||
self.location_table = {}
|
||||
self.collectible_table = {}
|
||||
self.collectible_override_flags_address = 0
|
||||
self.collectible_offsets = {}
|
||||
self.sending = []
|
||||
# list used to keep track of locations+items player has. Used for disoneccting
|
||||
self.kh2seedsave = None
|
||||
self.slotDataProgressionNames = {}
|
||||
self.kh2seedname = None
|
||||
self.kh2slotdata = None
|
||||
self.itemamount = {}
|
||||
# sora equipped, valor equipped, master equipped, final equipped
|
||||
self.keybladeAnchorList = (0x24F0, 0x32F4, 0x339C, 0x33D4)
|
||||
if "localappdata" in os.environ:
|
||||
self.game_communication_path = os.path.expandvars(r"%localappdata%\KH2AP")
|
||||
self.amountOfPieces = 0
|
||||
# hooked object
|
||||
self.kh2 = None
|
||||
self.ItemIsSafe = False
|
||||
self.game_connected = False
|
||||
self.finalxemnas = False
|
||||
self.worldid = {
|
||||
# 1: {}, # world of darkness (story cutscenes)
|
||||
2: TT_Checks,
|
||||
# 3: {}, # destiny island doesn't have checks to ima put tt checks here
|
||||
4: HB_Checks,
|
||||
5: BC_Checks,
|
||||
6: Oc_Checks,
|
||||
7: AG_Checks,
|
||||
8: LoD_Checks,
|
||||
9: HundredAcreChecks,
|
||||
10: PL_Checks,
|
||||
11: DC_Checks, # atlantica isn't a supported world. if you go in atlantica it will check dc
|
||||
12: DC_Checks,
|
||||
13: TR_Checks,
|
||||
14: HT_Checks,
|
||||
15: HB_Checks, # world map, but you only go to the world map while on the way to goa so checking hb
|
||||
16: PR_Checks,
|
||||
17: SP_Checks,
|
||||
18: TWTNW_Checks,
|
||||
# 255: {}, # starting screen
|
||||
}
|
||||
# 0x2A09C00+0x40 is the sve anchor. +1 is the last saved room
|
||||
self.sveroom = 0x2A09C00 + 0x41
|
||||
# 0 not in battle 1 in yellow battle 2 red battle #short
|
||||
self.inBattle = 0x2A0EAC4 + 0x40
|
||||
self.onDeath = 0xAB9078
|
||||
# PC Address anchors
|
||||
self.Now = 0x0714DB8
|
||||
self.Save = 0x09A70B0
|
||||
self.Sys3 = 0x2A59DF0
|
||||
self.Bt10 = 0x2A74880
|
||||
self.BtlEnd = 0x2A0D3E0
|
||||
self.Slot1 = 0x2A20C98
|
||||
|
||||
self.chest_set = set(exclusion_table["Chests"])
|
||||
|
||||
self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"])
|
||||
self.staff_set = set(CheckDupingItems["Weapons"]["Staffs"])
|
||||
self.shield_set = set(CheckDupingItems["Weapons"]["Shields"])
|
||||
|
||||
self.all_weapons = self.keyblade_set.union(self.staff_set).union(self.shield_set)
|
||||
|
||||
self.equipment_categories = CheckDupingItems["Equipment"]
|
||||
self.armor_set = set(self.equipment_categories["Armor"])
|
||||
self.accessories_set = set(self.equipment_categories["Accessories"])
|
||||
self.all_equipment = self.armor_set.union(self.accessories_set)
|
||||
|
||||
self.Equipment_Anchor_Dict = {
|
||||
"Armor": [0x2504, 0x2506, 0x2508, 0x250A],
|
||||
"Accessories": [0x2514, 0x2516, 0x2518, 0x251A]}
|
||||
|
||||
self.AbilityQuantityDict = {}
|
||||
self.ability_categories = CheckDupingItems["Abilities"]
|
||||
|
||||
self.sora_ability_set = set(self.ability_categories["Sora"])
|
||||
self.donald_ability_set = set(self.ability_categories["Donald"])
|
||||
self.goofy_ability_set = set(self.ability_categories["Goofy"])
|
||||
|
||||
self.all_abilities = self.sora_ability_set.union(self.donald_ability_set).union(self.goofy_ability_set)
|
||||
|
||||
self.boost_set = set(CheckDupingItems["Boosts"])
|
||||
self.stat_increase_set = set(CheckDupingItems["Stat Increases"])
|
||||
self.AbilityQuantityDict = {item: self.item_name_to_data[item].quantity for item in self.all_abilities}
|
||||
# Growth:[level 1,level 4,slot]
|
||||
self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25DA],
|
||||
"Quick Run": [0x62, 0x65, 0x25DC],
|
||||
"Dodge Roll": [0x234, 0x237, 0x25DE],
|
||||
"Aerial Dodge": [0x066, 0x069, 0x25E0],
|
||||
"Glide": [0x6A, 0x6D, 0x25E2]}
|
||||
self.boost_to_anchor_dict = {
|
||||
"Power Boost": 0x24F9,
|
||||
"Magic Boost": 0x24FA,
|
||||
"Defense Boost": 0x24FB,
|
||||
"AP Boost": 0x24F8}
|
||||
|
||||
self.AbilityCodeList = [self.item_name_to_data[item].code for item in exclusionItem_table["Ability"]]
|
||||
self.master_growth = {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}
|
||||
|
||||
self.bitmask_item_code = [
|
||||
0x130000, 0x130001, 0x130002, 0x130003, 0x130004, 0x130005, 0x130006, 0x130007
|
||||
, 0x130008, 0x130009, 0x13000A, 0x13000B, 0x13000C
|
||||
, 0x13001F, 0x130020, 0x130021, 0x130022, 0x130023
|
||||
, 0x13002A, 0x13002B, 0x13002C, 0x13002D]
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(KH2Context, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
async def connection_closed(self):
|
||||
self.kh2connected = False
|
||||
self.serverconneced = False
|
||||
if self.kh2seedname is not None and self.auth is not None:
|
||||
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
|
||||
'w') as f:
|
||||
f.write(json.dumps(self.kh2seedsave, indent=4))
|
||||
await super(KH2Context, self).connection_closed()
|
||||
|
||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||
self.kh2connected = False
|
||||
self.serverconneced = False
|
||||
if self.kh2seedname not in {None} and self.auth not in {None}:
|
||||
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
|
||||
'w') as f:
|
||||
f.write(json.dumps(self.kh2seedsave, indent=4))
|
||||
await super(KH2Context, self).disconnect()
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
if self.server:
|
||||
return [self.server]
|
||||
else:
|
||||
return []
|
||||
|
||||
async def shutdown(self):
|
||||
if self.kh2seedname not in {None} and self.auth not in {None}:
|
||||
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
|
||||
'w') as f:
|
||||
f.write(json.dumps(self.kh2seedsave, indent=4))
|
||||
await super(KH2Context, self).shutdown()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"RoomInfo"}:
|
||||
self.kh2seedname = args['seed_name']
|
||||
if not os.path.exists(self.game_communication_path):
|
||||
os.makedirs(self.game_communication_path)
|
||||
if not os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
|
||||
self.kh2seedsave = {"itemIndex": -1,
|
||||
# back of soras invo is 0x25E2. Growth should be moved there
|
||||
# Character: [back of invo, front of invo]
|
||||
"SoraInvo": [0x25D8, 0x2546],
|
||||
"DonaldInvo": [0x26F4, 0x2658],
|
||||
"GoofyInvo": [0x280A, 0x276C],
|
||||
"AmountInvo": {
|
||||
"ServerItems": {
|
||||
"Ability": {},
|
||||
"Amount": {},
|
||||
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
|
||||
"Aerial Dodge": 0,
|
||||
"Glide": 0},
|
||||
"Bitmask": [],
|
||||
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
|
||||
"Equipment": [],
|
||||
"Magic": {},
|
||||
"StatIncrease": {},
|
||||
"Boost": {},
|
||||
},
|
||||
"LocalItems": {
|
||||
"Ability": {},
|
||||
"Amount": {},
|
||||
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
|
||||
"Aerial Dodge": 0, "Glide": 0},
|
||||
"Bitmask": [],
|
||||
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
|
||||
"Equipment": [],
|
||||
"Magic": {},
|
||||
"StatIncrease": {},
|
||||
"Boost": {},
|
||||
}},
|
||||
# 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked
|
||||
"LocationsChecked": [],
|
||||
"Levels": {
|
||||
"SoraLevel": 0,
|
||||
"ValorLevel": 0,
|
||||
"WisdomLevel": 0,
|
||||
"LimitLevel": 0,
|
||||
"MasterLevel": 0,
|
||||
"FinalLevel": 0,
|
||||
},
|
||||
"SoldEquipment": [],
|
||||
"SoldBoosts": {"Power Boost": 0,
|
||||
"Magic Boost": 0,
|
||||
"Defense Boost": 0,
|
||||
"AP Boost": 0}
|
||||
}
|
||||
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
|
||||
'wt') as f:
|
||||
pass
|
||||
self.locations_checked = set()
|
||||
elif os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
|
||||
with open(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json", 'r') as f:
|
||||
self.kh2seedsave = json.load(f)
|
||||
self.locations_checked = set(self.kh2seedsave["LocationsChecked"])
|
||||
self.serverconneced = True
|
||||
|
||||
if cmd in {"Connected"}:
|
||||
self.kh2slotdata = args['slot_data']
|
||||
self.kh2LocalItems = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()}
|
||||
try:
|
||||
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
|
||||
logger.info("You are now auto-tracking")
|
||||
self.kh2connected = True
|
||||
except Exception as e:
|
||||
logger.info("Line 247")
|
||||
if self.kh2connected:
|
||||
logger.info("Connection Lost")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
if cmd in {"ReceivedItems"}:
|
||||
start_index = args["index"]
|
||||
if start_index == 0:
|
||||
# resetting everything that were sent from the server
|
||||
self.kh2seedsave["SoraInvo"][0] = 0x25D8
|
||||
self.kh2seedsave["DonaldInvo"][0] = 0x26F4
|
||||
self.kh2seedsave["GoofyInvo"][0] = 0x280A
|
||||
self.kh2seedsave["itemIndex"] = - 1
|
||||
self.kh2seedsave["AmountInvo"]["ServerItems"] = {
|
||||
"Ability": {},
|
||||
"Amount": {},
|
||||
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
|
||||
"Aerial Dodge": 0,
|
||||
"Glide": 0},
|
||||
"Bitmask": [],
|
||||
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
|
||||
"Equipment": [],
|
||||
"Magic": {},
|
||||
"StatIncrease": {},
|
||||
"Boost": {},
|
||||
}
|
||||
if start_index > self.kh2seedsave["itemIndex"]:
|
||||
self.kh2seedsave["itemIndex"] = start_index
|
||||
for item in args['items']:
|
||||
asyncio.create_task(self.give_item(item.item))
|
||||
|
||||
if cmd in {"RoomUpdate"}:
|
||||
if "checked_locations" in args:
|
||||
new_locations = set(args["checked_locations"])
|
||||
# TODO: make this take locations from other players on the same slot so proper coop happens
|
||||
# items_to_give = [self.kh2slotdata["LocalItems"][str(location_id)] for location_id in new_locations if
|
||||
# location_id in self.kh2LocalItems.keys()]
|
||||
self.checked_locations |= new_locations
|
||||
|
||||
async def checkWorldLocations(self):
|
||||
try:
|
||||
currentworldint = int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big")
|
||||
if currentworldint in self.worldid:
|
||||
curworldid = self.worldid[currentworldint]
|
||||
for location, data in curworldid.items():
|
||||
locationId = kh2_loc_name_to_id[location]
|
||||
if locationId not in self.locations_checked \
|
||||
and (int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
|
||||
"big") & 0x1 << data.bitIndex) > 0:
|
||||
self.sending = self.sending + [(int(locationId))]
|
||||
except Exception as e:
|
||||
logger.info("Line 285")
|
||||
if self.kh2connected:
|
||||
logger.info("Connection Lost.")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
async def checkLevels(self):
|
||||
try:
|
||||
for location, data in SoraLevels.items():
|
||||
currentLevel = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), "big")
|
||||
locationId = kh2_loc_name_to_id[location]
|
||||
if locationId not in self.locations_checked \
|
||||
and currentLevel >= data.bitIndex:
|
||||
if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel:
|
||||
self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel
|
||||
self.sending = self.sending + [(int(locationId))]
|
||||
formDict = {
|
||||
0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels],
|
||||
3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels]}
|
||||
for i in range(5):
|
||||
for location, data in formDict[i][1].items():
|
||||
formlevel = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big")
|
||||
locationId = kh2_loc_name_to_id[location]
|
||||
if locationId not in self.locations_checked \
|
||||
and formlevel >= data.bitIndex:
|
||||
if formlevel > self.kh2seedsave["Levels"][formDict[i][0]]:
|
||||
self.kh2seedsave["Levels"][formDict[i][0]] = formlevel
|
||||
self.sending = self.sending + [(int(locationId))]
|
||||
except Exception as e:
|
||||
logger.info("Line 312")
|
||||
if self.kh2connected:
|
||||
logger.info("Connection Lost.")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
async def checkSlots(self):
|
||||
try:
|
||||
for location, data in weaponSlots.items():
|
||||
locationId = kh2_loc_name_to_id[location]
|
||||
if locationId not in self.locations_checked:
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
|
||||
"big") > 0:
|
||||
self.sending = self.sending + [(int(locationId))]
|
||||
|
||||
for location, data in formSlots.items():
|
||||
locationId = kh2_loc_name_to_id[location]
|
||||
if locationId not in self.locations_checked:
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
|
||||
"big") & 0x1 << data.bitIndex > 0:
|
||||
# self.locations_checked
|
||||
self.sending = self.sending + [(int(locationId))]
|
||||
|
||||
except Exception as e:
|
||||
if self.kh2connected:
|
||||
logger.info("Line 333")
|
||||
logger.info("Connection Lost.")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
async def verifyChests(self):
|
||||
try:
|
||||
for location in self.locations_checked:
|
||||
locationName = self.lookup_id_to_Location[location]
|
||||
if locationName in self.chest_set:
|
||||
if locationName in self.location_name_to_worlddata.keys():
|
||||
locationData = self.location_name_to_worlddata[locationName]
|
||||
if int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
|
||||
"big") & 0x1 << locationData.bitIndex == 0:
|
||||
roomData = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
|
||||
1), "big")
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
|
||||
(roomData | 0x01 << locationData.bitIndex).to_bytes(1, 'big'), 1)
|
||||
|
||||
except Exception as e:
|
||||
if self.kh2connected:
|
||||
logger.info("Line 350")
|
||||
logger.info("Connection Lost.")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
async def verifyLevel(self):
|
||||
for leveltype, anchor in {"SoraLevel": 0x24FF,
|
||||
"ValorLevel": 0x32F6,
|
||||
"WisdomLevel": 0x332E,
|
||||
"LimitLevel": 0x3366,
|
||||
"MasterLevel": 0x339E,
|
||||
"FinalLevel": 0x33D6}.items():
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + anchor, 1), "big") < \
|
||||
self.kh2seedsave["Levels"][leveltype]:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + anchor,
|
||||
(self.kh2seedsave["Levels"][leveltype]).to_bytes(1, 'big'), 1)
|
||||
|
||||
async def give_item(self, item, ItemType="ServerItems"):
|
||||
try:
|
||||
itemname = self.lookup_id_to_item[item]
|
||||
itemcode = self.item_name_to_data[itemname]
|
||||
if itemcode.ability:
|
||||
abilityInvoType = 0
|
||||
TwilightZone = 2
|
||||
if ItemType == "LocalItems":
|
||||
abilityInvoType = 1
|
||||
TwilightZone = -2
|
||||
if itemname in {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Growth"][itemname] += 1
|
||||
return
|
||||
|
||||
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Ability"]:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname] = []
|
||||
# appending the slot that the ability should be in
|
||||
|
||||
if len(self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname]) < \
|
||||
self.AbilityQuantityDict[itemname]:
|
||||
if itemname in self.sora_ability_set:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
|
||||
self.kh2seedsave["SoraInvo"][abilityInvoType])
|
||||
self.kh2seedsave["SoraInvo"][abilityInvoType] -= TwilightZone
|
||||
elif itemname in self.donald_ability_set:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
|
||||
self.kh2seedsave["DonaldInvo"][abilityInvoType])
|
||||
self.kh2seedsave["DonaldInvo"][abilityInvoType] -= TwilightZone
|
||||
else:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
|
||||
self.kh2seedsave["GoofyInvo"][abilityInvoType])
|
||||
self.kh2seedsave["GoofyInvo"][abilityInvoType] -= TwilightZone
|
||||
|
||||
elif itemcode.code in self.bitmask_item_code:
|
||||
|
||||
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"]:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"].append(itemname)
|
||||
|
||||
elif itemcode.memaddr in {0x3594, 0x3595, 0x3596, 0x3597, 0x35CF, 0x35D0}:
|
||||
|
||||
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Magic"]:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] += 1
|
||||
else:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] = 1
|
||||
elif itemname in self.all_equipment:
|
||||
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Equipment"].append(itemname)
|
||||
|
||||
elif itemname in self.all_weapons:
|
||||
if itemname in self.keyblade_set:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Sora"].append(itemname)
|
||||
elif itemname in self.staff_set:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Donald"].append(itemname)
|
||||
else:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Goofy"].append(itemname)
|
||||
|
||||
elif itemname in self.boost_set:
|
||||
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Boost"]:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] += 1
|
||||
else:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] = 1
|
||||
|
||||
elif itemname in self.stat_increase_set:
|
||||
|
||||
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"]:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] += 1
|
||||
else:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] = 1
|
||||
|
||||
else:
|
||||
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Amount"]:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] += 1
|
||||
else:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] = 1
|
||||
|
||||
except Exception as e:
|
||||
if self.kh2connected:
|
||||
logger.info("Line 398")
|
||||
logger.info("Connection Lost.")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager
|
||||
|
||||
class KH2Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago KH2 Client"
|
||||
|
||||
self.ui = KH2Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
async def IsInShop(self, sellable, master_boost):
|
||||
# journal = 0x741230 shop = 0x741320
|
||||
# if journal=-1 and shop = 5 then in shop
|
||||
# if journam !=-1 and shop = 10 then journal
|
||||
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
|
||||
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
|
||||
if (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
|
||||
# print("your in the shop")
|
||||
sellable_dict = {}
|
||||
for itemName in sellable:
|
||||
itemdata = self.item_name_to_data[itemName]
|
||||
amount = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
|
||||
sellable_dict[itemName] = amount
|
||||
while (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
|
||||
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
|
||||
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
|
||||
await asyncio.sleep(0.5)
|
||||
for item, amount in sellable_dict.items():
|
||||
itemdata = self.item_name_to_data[item]
|
||||
afterShop = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
|
||||
if afterShop < amount:
|
||||
if item in master_boost:
|
||||
self.kh2seedsave["SoldBoosts"][item] += (amount - afterShop)
|
||||
else:
|
||||
self.kh2seedsave["SoldEquipment"].append(item)
|
||||
|
||||
async def verifyItems(self):
|
||||
try:
|
||||
local_amount = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"].keys())
|
||||
server_amount = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"].keys())
|
||||
master_amount = local_amount | server_amount
|
||||
|
||||
local_ability = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"].keys())
|
||||
server_ability = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"].keys())
|
||||
master_ability = local_ability | server_ability
|
||||
|
||||
local_bitmask = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Bitmask"])
|
||||
server_bitmask = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Bitmask"])
|
||||
master_bitmask = local_bitmask | server_bitmask
|
||||
|
||||
local_keyblade = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Sora"])
|
||||
local_staff = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Donald"])
|
||||
local_shield = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Goofy"])
|
||||
|
||||
server_keyblade = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Sora"])
|
||||
server_staff = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Donald"])
|
||||
server_shield = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Goofy"])
|
||||
|
||||
master_keyblade = local_keyblade | server_keyblade
|
||||
master_staff = local_staff | server_staff
|
||||
master_shield = local_shield | server_shield
|
||||
|
||||
local_equipment = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Equipment"])
|
||||
server_equipment = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Equipment"])
|
||||
master_equipment = local_equipment | server_equipment
|
||||
|
||||
local_magic = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"].keys())
|
||||
server_magic = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"].keys())
|
||||
master_magic = local_magic | server_magic
|
||||
|
||||
local_stat = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"].keys())
|
||||
server_stat = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"].keys())
|
||||
master_stat = local_stat | server_stat
|
||||
|
||||
local_boost = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"].keys())
|
||||
server_boost = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"].keys())
|
||||
master_boost = local_boost | server_boost
|
||||
|
||||
master_sell = master_equipment | master_staff | master_shield | master_boost
|
||||
await asyncio.create_task(self.IsInShop(master_sell, master_boost))
|
||||
for itemName in master_amount:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
amountOfItems = 0
|
||||
if itemName in local_amount:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"][itemName]
|
||||
if itemName in server_amount:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"][itemName]
|
||||
|
||||
if itemName == "Torn Page":
|
||||
# Torn Pages are handled differently because they can be consumed.
|
||||
# Will check the progression in 100 acre and - the amount of visits
|
||||
# amountofitems-amount of visits done
|
||||
for location, data in tornPageLocks.items():
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
|
||||
"big") & 0x1 << data.bitIndex > 0:
|
||||
amountOfItems -= 1
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != amountOfItems and amountOfItems >= 0:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
amountOfItems.to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_keyblade:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
# if the inventory slot for that keyblade is less than the amount they should have
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != 1 and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x1CFF, 1),
|
||||
"big") != 13:
|
||||
# Checking form anchors for the keyblade
|
||||
if self.kh2.read_short(self.kh2.base_address + self.Save + 0x24F0) == itemData.kh2id \
|
||||
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x32F4) == itemData.kh2id \
|
||||
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x339C) == itemData.kh2id \
|
||||
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x33D4) == itemData.kh2id:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(0).to_bytes(1, 'big'), 1)
|
||||
else:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(1).to_bytes(1, 'big'), 1)
|
||||
for itemName in master_staff:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != 1 \
|
||||
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2604) != itemData.kh2id \
|
||||
and itemName not in self.kh2seedsave["SoldEquipment"]:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(1).to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_shield:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != 1 \
|
||||
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2718) != itemData.kh2id \
|
||||
and itemName not in self.kh2seedsave["SoldEquipment"]:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(1).to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_ability:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
ability_slot = []
|
||||
if itemName in local_ability:
|
||||
ability_slot += self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"][itemName]
|
||||
if itemName in server_ability:
|
||||
ability_slot += self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"][itemName]
|
||||
for slot in ability_slot:
|
||||
current = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
|
||||
ability = current & 0x0FFF
|
||||
if ability | 0x8000 != (0x8000 + itemData.memaddr):
|
||||
if current - 0x8000 > 0:
|
||||
self.kh2.write_short(self.kh2.base_address + self.Save + slot, (0x8000 + itemData.memaddr))
|
||||
else:
|
||||
self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr)
|
||||
# removes the duped ability if client gave faster than the game.
|
||||
for charInvo in {"SoraInvo", "DonaldInvo", "GoofyInvo"}:
|
||||
if self.kh2.read_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1]) != 0 and \
|
||||
self.kh2seedsave[charInvo][1] + 2 < self.kh2seedsave[charInvo][0]:
|
||||
self.kh2.write_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1], 0)
|
||||
# remove the dummy level 1 growths if they are in these invo slots.
|
||||
for inventorySlot in {0x25CE, 0x25D0, 0x25D2, 0x25D4, 0x25D6, 0x25D8}:
|
||||
current = self.kh2.read_short(self.kh2.base_address + self.Save + inventorySlot)
|
||||
ability = current & 0x0FFF
|
||||
if 0x05E <= ability <= 0x06D:
|
||||
self.kh2.write_short(self.kh2.base_address + self.Save + inventorySlot, 0)
|
||||
|
||||
for itemName in self.master_growth:
|
||||
growthLevel = self.kh2seedsave["AmountInvo"]["ServerItems"]["Growth"][itemName] \
|
||||
+ self.kh2seedsave["AmountInvo"]["LocalItems"]["Growth"][itemName]
|
||||
if growthLevel > 0:
|
||||
slot = self.growth_values_dict[itemName][2]
|
||||
min_growth = self.growth_values_dict[itemName][0]
|
||||
max_growth = self.growth_values_dict[itemName][1]
|
||||
if growthLevel > 4:
|
||||
growthLevel = 4
|
||||
current_growth_level = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
|
||||
ability = current_growth_level & 0x0FFF
|
||||
# if the player should be getting a growth ability
|
||||
if ability | 0x8000 != 0x8000 + min_growth - 1 + growthLevel:
|
||||
# if it should be level one of that growth
|
||||
if 0x8000 + min_growth - 1 + growthLevel <= 0x8000 + min_growth or ability < min_growth:
|
||||
self.kh2.write_short(self.kh2.base_address + self.Save + slot, min_growth)
|
||||
# if it is already in the inventory
|
||||
elif ability | 0x8000 < (0x8000 + max_growth):
|
||||
self.kh2.write_short(self.kh2.base_address + self.Save + slot, current_growth_level + 1)
|
||||
|
||||
for itemName in master_bitmask:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
itemMemory = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big")
|
||||
if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") & 0x1 << itemData.bitmask) == 0:
|
||||
# when getting a form anti points should be reset to 0 but bit-shift doesn't trigger the game.
|
||||
if itemName in {"Valor Form", "Wisdom Form", "Limit Form", "Master Form", "Final Form"}:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + 0x3410,
|
||||
(0).to_bytes(1, 'big'), 1)
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(itemMemory | 0x01 << itemData.bitmask).to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_equipment:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
isThere = False
|
||||
if itemName in self.accessories_set:
|
||||
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Accessories"]
|
||||
else:
|
||||
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Armor"]
|
||||
# Checking form anchors for the equipment
|
||||
for slot in Equipment_Anchor_List:
|
||||
if self.kh2.read_short(self.kh2.base_address + self.Save + slot) == itemData.kh2id:
|
||||
isThere = True
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != 0:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(0).to_bytes(1, 'big'), 1)
|
||||
break
|
||||
if not isThere and itemName not in self.kh2seedsave["SoldEquipment"]:
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != 1:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(1).to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_magic:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
amountOfItems = 0
|
||||
if itemName in local_magic:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"][itemName]
|
||||
if itemName in server_magic:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"][itemName]
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != amountOfItems \
|
||||
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x741320, 1), "big") in {10, 8}:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
amountOfItems.to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_stat:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
amountOfItems = 0
|
||||
if itemName in local_stat:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"][itemName]
|
||||
if itemName in server_stat:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"][itemName]
|
||||
|
||||
# 0x130293 is Crit_1's location id for touching the computer
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != amountOfItems \
|
||||
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Slot1 + 0x1B2, 1),
|
||||
"big") >= 5 and int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x23DF, 1),
|
||||
"big") > 0:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
amountOfItems.to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_boost:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
amountOfItems = 0
|
||||
if itemName in local_boost:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"][itemName]
|
||||
if itemName in server_boost:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"][itemName]
|
||||
amountOfBoostsInInvo = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big")
|
||||
amountOfUsedBoosts = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + self.boost_to_anchor_dict[itemName], 1),
|
||||
"big")
|
||||
# Ap Boots start at +50 for some reason
|
||||
if itemName == "AP Boost":
|
||||
amountOfUsedBoosts -= 50
|
||||
totalBoosts = (amountOfBoostsInInvo + amountOfUsedBoosts)
|
||||
if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][
|
||||
itemName] and amountOfBoostsInInvo < 255:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(amountOfBoostsInInvo + 1).to_bytes(1, 'big'), 1)
|
||||
|
||||
except Exception as e:
|
||||
logger.info("Line 573")
|
||||
if self.kh2connected:
|
||||
logger.info("Connection Lost.")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
|
||||
def finishedGame(ctx: KH2Context, message):
|
||||
if ctx.kh2slotdata['FinalXemnas'] == 1:
|
||||
if 0x1301ED in message[0]["locations"]:
|
||||
ctx.finalxemnas = True
|
||||
# three proofs
|
||||
if ctx.kh2slotdata['Goal'] == 0:
|
||||
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, 1), "big") > 0 \
|
||||
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, 1), "big") > 0 \
|
||||
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, 1), "big") > 0:
|
||||
if ctx.kh2slotdata['FinalXemnas'] == 1:
|
||||
if ctx.finalxemnas:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
elif ctx.kh2slotdata['Goal'] == 1:
|
||||
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x3641, 1), "big") >= \
|
||||
ctx.kh2slotdata['LuckyEmblemsRequired']:
|
||||
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
|
||||
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
|
||||
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
|
||||
if ctx.kh2slotdata['FinalXemnas'] == 1:
|
||||
if ctx.finalxemnas:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
elif ctx.kh2slotdata['Goal'] == 2:
|
||||
for boss in ctx.kh2slotdata["hitlist"]:
|
||||
if boss in message[0]["locations"]:
|
||||
ctx.amountOfPieces += 1
|
||||
if ctx.amountOfPieces >= ctx.kh2slotdata["BountyRequired"]:
|
||||
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
|
||||
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
|
||||
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
|
||||
if ctx.kh2slotdata['FinalXemnas'] == 1:
|
||||
if ctx.finalxemnas:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
async def kh2_watcher(ctx: KH2Context):
|
||||
while not ctx.exit_event.is_set():
|
||||
try:
|
||||
if ctx.kh2connected and ctx.serverconneced:
|
||||
ctx.sending = []
|
||||
await asyncio.create_task(ctx.checkWorldLocations())
|
||||
await asyncio.create_task(ctx.checkLevels())
|
||||
await asyncio.create_task(ctx.checkSlots())
|
||||
await asyncio.create_task(ctx.verifyChests())
|
||||
await asyncio.create_task(ctx.verifyItems())
|
||||
await asyncio.create_task(ctx.verifyLevel())
|
||||
message = [{"cmd": 'LocationChecks', "locations": ctx.sending}]
|
||||
if finishedGame(ctx, message):
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
location_ids = []
|
||||
location_ids = [location for location in message[0]["locations"] if location not in location_ids]
|
||||
for location in location_ids:
|
||||
if location not in ctx.locations_checked:
|
||||
ctx.locations_checked.add(location)
|
||||
ctx.kh2seedsave["LocationsChecked"].append(location)
|
||||
if location in ctx.kh2LocalItems:
|
||||
item = ctx.kh2slotdata["LocalItems"][str(location)]
|
||||
await asyncio.create_task(ctx.give_item(item, "LocalItems"))
|
||||
await ctx.send_msgs(message)
|
||||
elif not ctx.kh2connected and ctx.serverconneced:
|
||||
logger.info("Game is not open. Disconnecting from Server.")
|
||||
await ctx.disconnect()
|
||||
except Exception as e:
|
||||
logger.info("Line 661")
|
||||
if ctx.kh2connected:
|
||||
logger.info("Connection Lost.")
|
||||
ctx.kh2connected = False
|
||||
logger.info(e)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
async def main(args):
|
||||
ctx = KH2Context(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
progression_watcher = asyncio.create_task(
|
||||
kh2_watcher(ctx), name="KH2ProgressionWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await progression_watcher
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="KH2 Client, for text interfacing.")
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
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
|
||||
|
||||
281
Launcher.py
Normal file
281
Launcher.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
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
|
||||
import itertools
|
||||
import logging
|
||||
import multiprocessing
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import webbrowser
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
from typing import Sequence, Union, Optional
|
||||
|
||||
import Utils
|
||||
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
|
||||
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
|
||||
is_windows, is_macos, is_linux
|
||||
|
||||
|
||||
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:
|
||||
webbrowser.open(file)
|
||||
|
||||
|
||||
def open_patch():
|
||||
suffixes = []
|
||||
for c in components:
|
||||
if isfile(get_exe(c)[-1]):
|
||||
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
|
||||
isinstance(c.file_identifier, SuffixIdentifier) else []
|
||||
try:
|
||||
filename = open_filename('Select patch', (('Patches', suffixes),))
|
||||
except Exception as e:
|
||||
messagebox('Error', str(e), error=True)
|
||||
else:
|
||||
file, component = identify(filename)
|
||||
if file and component:
|
||||
launch([*get_exe(component), file], component.cli)
|
||||
|
||||
|
||||
def generate_yamls():
|
||||
from Options import generate_yaml_templates
|
||||
|
||||
target = Utils.user_path("Players", "Templates")
|
||||
generate_yaml_templates(target, False)
|
||||
open_folder(target)
|
||||
|
||||
|
||||
def browse_files():
|
||||
open_folder(user_path())
|
||||
|
||||
|
||||
def open_folder(folder_path):
|
||||
if is_linux:
|
||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
subprocess.Popen([exe, folder_path])
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, folder_path])
|
||||
else:
|
||||
webbrowser.open(folder_path)
|
||||
|
||||
|
||||
components.extend([
|
||||
# Functions
|
||||
Component("Open host.yaml", func=open_host_yaml),
|
||||
Component("Open Patch", func=open_patch),
|
||||
Component("Generate Template Settings", func=generate_yamls),
|
||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
||||
Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
||||
Component("Browse Files", func=browse_files),
|
||||
])
|
||||
|
||||
|
||||
def identify(path: Union[None, str]):
|
||||
if path is None:
|
||||
return None, None
|
||||
for component in components:
|
||||
if component.handles_file(path):
|
||||
return path, component
|
||||
elif path == component.display_name or path == component.script_name:
|
||||
return None, component
|
||||
return None, None
|
||||
|
||||
|
||||
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
||||
if isinstance(component, str):
|
||||
name = component
|
||||
component = None
|
||||
if name.startswith('Archipelago'):
|
||||
name = name[11:]
|
||||
if name.endswith('.exe'):
|
||||
name = name[:-4]
|
||||
if name.endswith('.py'):
|
||||
name = name[:-3]
|
||||
if not name:
|
||||
return None
|
||||
for c in components:
|
||||
if c.script_name == name or c.frozen_name == f'Archipelago{name}':
|
||||
component = c
|
||||
break
|
||||
if not component:
|
||||
return None
|
||||
if is_frozen():
|
||||
suffix = '.exe' if is_windows else ''
|
||||
return [local_path(f'{component.frozen_name}{suffix}')]
|
||||
else:
|
||||
return [sys.executable, local_path(f'{component.script_name}.py')]
|
||||
|
||||
|
||||
def launch(exe, in_terminal=False):
|
||||
if in_terminal:
|
||||
if is_windows:
|
||||
subprocess.Popen(['start', *exe], shell=True)
|
||||
return
|
||||
elif is_linux:
|
||||
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
||||
if terminal:
|
||||
subprocess.Popen([terminal, '-e', shlex.join(exe)])
|
||||
return
|
||||
elif is_macos:
|
||||
terminal = [which('open'), '-W', '-a', 'Terminal.app']
|
||||
subprocess.Popen([*terminal, *exe])
|
||||
return
|
||||
subprocess.Popen(exe)
|
||||
|
||||
|
||||
def run_gui():
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label
|
||||
from kivy.uix.image import AsyncImage
|
||||
from kivy.uix.relativelayout import RelativeLayout
|
||||
|
||||
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}
|
||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
||||
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
||||
|
||||
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)
|
||||
self.grid.add_widget(Label(text="General"))
|
||||
self.grid.add_widget(Label(text="Clients"))
|
||||
button_layout = self.grid # make buttons fill the window
|
||||
|
||||
def build_button(component: Component):
|
||||
"""
|
||||
Builds a button widget for a given component.
|
||||
|
||||
Args:
|
||||
component (Component): The component associated with the button.
|
||||
|
||||
Returns:
|
||||
None. The button is added to the parent grid layout.
|
||||
|
||||
"""
|
||||
button = Button(text=component.display_name)
|
||||
button.component = component
|
||||
button.bind(on_release=self.component_action)
|
||||
if component.icon != "icon":
|
||||
image = AsyncImage(source=icon_paths[component.icon],
|
||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
||||
box_layout = RelativeLayout()
|
||||
box_layout.add_widget(button)
|
||||
box_layout.add_widget(image)
|
||||
button_layout.add_widget(box_layout)
|
||||
else:
|
||||
button_layout.add_widget(button)
|
||||
|
||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
|
||||
# column 1
|
||||
if tool:
|
||||
build_button(tool[1])
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
# column 2
|
||||
if client:
|
||||
build_button(client[1])
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
|
||||
return self.container
|
||||
|
||||
@staticmethod
|
||||
def component_action(button):
|
||||
if button.component.func:
|
||||
button.component.func()
|
||||
else:
|
||||
launch(get_exe(button.component), button.component.cli)
|
||||
|
||||
def _stop(self, *largs):
|
||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||
# Closing the window explicitly cleans it up.
|
||||
self.root_window.close()
|
||||
super()._stop(*largs)
|
||||
|
||||
Launcher().run()
|
||||
|
||||
|
||||
def run_component(component: Component, *args):
|
||||
if component.func:
|
||||
component.func(*args)
|
||||
elif component.script_name:
|
||||
subprocess.run([*get_exe(component.script_name), *args])
|
||||
else:
|
||||
logging.warning(f"Component {component} does not appear to be executable.")
|
||||
|
||||
|
||||
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 not component:
|
||||
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
|
||||
|
||||
if 'file' in args:
|
||||
run_component(args["component"], args["file"], *args["args"])
|
||||
elif 'component' in args:
|
||||
run_component(args["component"], *args["args"])
|
||||
else:
|
||||
run_gui()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_logging('Launcher')
|
||||
Utils.freeze_support()
|
||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||
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())
|
||||
|
||||
from worlds.LauncherComponents import processes
|
||||
for process in processes:
|
||||
# we await all child processes to close before we tear down the process host
|
||||
# this makes it feel like each one is its own program, as the Launcher is closed now
|
||||
process.join()
|
||||
613
LinksAwakeningClient.py
Normal file
613
LinksAwakeningClient.py
Normal file
@@ -0,0 +1,613 @@
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import io
|
||||
import logging
|
||||
import select
|
||||
import socket
|
||||
import time
|
||||
import typing
|
||||
import urllib
|
||||
|
||||
import colorama
|
||||
import struct
|
||||
|
||||
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
||||
server_loop)
|
||||
from NetUtils import ClientStatus
|
||||
from worlds.ladx.Common import BASE_ID as LABaseID
|
||||
from worlds.ladx.GpsTracker import GpsTracker
|
||||
from worlds.ladx.ItemTracker import ItemTracker
|
||||
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
||||
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
||||
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
|
||||
|
||||
class GameboyException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RetroArchDisconnectError(GameboyException):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidEmulatorStateError(GameboyException):
|
||||
pass
|
||||
|
||||
|
||||
class BadRetroArchResponse(GameboyException):
|
||||
pass
|
||||
|
||||
|
||||
def magpie_logo():
|
||||
from kivy.uix.image import CoreImage
|
||||
binary_data = """
|
||||
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
|
||||
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
|
||||
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
|
||||
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
|
||||
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
|
||||
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
|
||||
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
|
||||
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
|
||||
binary_data = base64.b64decode(binary_data)
|
||||
data = io.BytesIO(binary_data)
|
||||
return CoreImage(data, ext="png").texture
|
||||
|
||||
|
||||
class LAClientConstants:
|
||||
# Connector version
|
||||
VERSION = 0x01
|
||||
#
|
||||
# Memory locations of LADXR
|
||||
ROMGameID = 0x0051 # 4 bytes
|
||||
SlotName = 0x0134
|
||||
# Unused
|
||||
# ROMWorldID = 0x0055
|
||||
# ROMConnectorVersion = 0x0056
|
||||
# RO: We should only act if this is higher then 6, as it indicates that the game is running normally
|
||||
wGameplayType = 0xDB95
|
||||
# RO: Starts at 0, increases every time an item is received from the server and processed
|
||||
wLinkSyncSequenceNumber = 0xDDF6
|
||||
wLinkStatusBits = 0xDDF7 # RW:
|
||||
# Bit0: wLinkGive* contains valid data, set from script cleared from ROM.
|
||||
wLinkHealth = 0xDB5A
|
||||
wLinkGiveItem = 0xDDF8 # RW
|
||||
wLinkGiveItemFrom = 0xDDF9 # RW
|
||||
# All of these six bytes are unused, we can repurpose
|
||||
# wLinkSendItemRoomHigh = 0xDDFA # RO
|
||||
# wLinkSendItemRoomLow = 0xDDFB # RO
|
||||
# wLinkSendItemTarget = 0xDDFC # RO
|
||||
# wLinkSendItemItem = 0xDDFD # RO
|
||||
# wLinkSendShopItem = 0xDDFE # RO, which item to send (1 based, order of the shop items)
|
||||
# RO, which player to send to, but it's just the X position of the NPC used, so 0x18 is player 0
|
||||
# wLinkSendShopTarget = 0xDDFF
|
||||
|
||||
|
||||
wRecvIndex = 0xDDFD # Two bytes
|
||||
wCheckAddress = 0xC0FF - 0x4
|
||||
WRamCheckSize = 0x4
|
||||
WRamSafetyValue = bytearray([0]*WRamCheckSize)
|
||||
|
||||
MinGameplayValue = 0x06
|
||||
MaxGameplayValue = 0x1A
|
||||
VictoryGameplayAndSub = 0x0102
|
||||
|
||||
|
||||
class RAGameboy():
|
||||
cache = []
|
||||
cache_start = 0
|
||||
cache_size = 0
|
||||
last_cache_read = None
|
||||
socket = None
|
||||
|
||||
def __init__(self, address, port) -> None:
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
assert (self.socket)
|
||||
self.socket.setblocking(False)
|
||||
|
||||
def get_retroarch_version(self):
|
||||
self.send(b'VERSION\n')
|
||||
select.select([self.socket], [], [])
|
||||
response_str, addr = self.socket.recvfrom(16)
|
||||
return response_str.rstrip()
|
||||
|
||||
def get_retroarch_status(self, timeout):
|
||||
self.send(b'GET_STATUS\n')
|
||||
select.select([self.socket], [], [], timeout)
|
||||
response_str, addr = self.socket.recvfrom(1000, )
|
||||
return response_str.rstrip()
|
||||
|
||||
def set_cache_limits(self, cache_start, cache_size):
|
||||
self.cache_start = cache_start
|
||||
self.cache_size = cache_size
|
||||
|
||||
def send(self, b):
|
||||
if type(b) is str:
|
||||
b = b.encode('ascii')
|
||||
self.socket.sendto(b, (self.address, self.port))
|
||||
|
||||
def recv(self):
|
||||
select.select([self.socket], [], [])
|
||||
response, _ = self.socket.recvfrom(4096)
|
||||
return response
|
||||
|
||||
async def async_recv(self):
|
||||
response = await asyncio.get_event_loop().sock_recv(self.socket, 4096)
|
||||
return response
|
||||
|
||||
async def check_safe_gameplay(self, throw=True):
|
||||
async def check_wram():
|
||||
check_values = await self.async_read_memory(LAClientConstants.wCheckAddress, LAClientConstants.WRamCheckSize)
|
||||
|
||||
if check_values != LAClientConstants.WRamSafetyValue:
|
||||
if throw:
|
||||
raise InvalidEmulatorStateError()
|
||||
return False
|
||||
return True
|
||||
|
||||
if not await check_wram():
|
||||
if throw:
|
||||
raise InvalidEmulatorStateError()
|
||||
return False
|
||||
|
||||
gameplay_value = await self.async_read_memory(LAClientConstants.wGameplayType)
|
||||
gameplay_value = gameplay_value[0]
|
||||
# In gameplay or credits
|
||||
if not (LAClientConstants.MinGameplayValue <= gameplay_value <= LAClientConstants.MaxGameplayValue) and gameplay_value != 0x1:
|
||||
if throw:
|
||||
logger.info("invalid emu state")
|
||||
raise InvalidEmulatorStateError()
|
||||
return False
|
||||
if not await check_wram():
|
||||
return False
|
||||
return True
|
||||
|
||||
# We're sadly unable to update the whole cache at once
|
||||
# as RetroArch only gives back some number of bytes at a time
|
||||
# So instead read as big as chunks at a time as we can manage
|
||||
async def update_cache(self):
|
||||
# First read the safety address - if it's invalid, bail
|
||||
self.cache = []
|
||||
|
||||
if not await self.check_safe_gameplay():
|
||||
return
|
||||
|
||||
cache = []
|
||||
remaining_size = self.cache_size
|
||||
while remaining_size:
|
||||
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
|
||||
remaining_size -= len(block)
|
||||
cache += block
|
||||
|
||||
if not await self.check_safe_gameplay():
|
||||
return
|
||||
|
||||
self.cache = cache
|
||||
self.last_cache_read = time.time()
|
||||
|
||||
async def read_memory_cache(self, addresses):
|
||||
# TODO: can we just update once per frame?
|
||||
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
|
||||
await self.update_cache()
|
||||
if not self.cache:
|
||||
return None
|
||||
assert (len(self.cache) == self.cache_size)
|
||||
for address in addresses:
|
||||
assert self.cache_start <= address <= self.cache_start + self.cache_size
|
||||
r = {address: self.cache[address - self.cache_start]
|
||||
for address in addresses}
|
||||
return r
|
||||
|
||||
async def async_read_memory_safe(self, address, size=1):
|
||||
# whenever we do a read for a check, we need to make sure that we aren't reading
|
||||
# garbage memory values - we also need to protect against reading a value, then the emulator resetting
|
||||
#
|
||||
# ...actually, we probably _only_ need the post check
|
||||
|
||||
# Check before read
|
||||
if not await self.check_safe_gameplay():
|
||||
return None
|
||||
|
||||
# Do read
|
||||
r = await self.async_read_memory(address, size)
|
||||
|
||||
# Check after read
|
||||
if not await self.check_safe_gameplay():
|
||||
return None
|
||||
|
||||
return r
|
||||
|
||||
def read_memory(self, address, size=1):
|
||||
command = "READ_CORE_MEMORY"
|
||||
|
||||
self.send(f'{command} {hex(address)} {size}\n')
|
||||
response = self.recv()
|
||||
|
||||
splits = response.decode().split(" ", 2)
|
||||
|
||||
assert (splits[0] == command)
|
||||
# Ignore the address for now
|
||||
|
||||
# TODO: transform to bytes
|
||||
if splits[2][:2] == "-1" or splits[0] != "READ_CORE_MEMORY":
|
||||
raise BadRetroArchResponse()
|
||||
return bytearray.fromhex(splits[2])
|
||||
|
||||
async def async_read_memory(self, address, size=1):
|
||||
command = "READ_CORE_MEMORY"
|
||||
|
||||
self.send(f'{command} {hex(address)} {size}\n')
|
||||
response = await self.async_recv()
|
||||
response = response[:-1]
|
||||
splits = response.decode().split(" ", 2)
|
||||
|
||||
assert (splits[0] == command)
|
||||
# Ignore the address for now
|
||||
|
||||
# TODO: transform to bytes
|
||||
return bytearray.fromhex(splits[2])
|
||||
|
||||
def write_memory(self, address, bytes):
|
||||
command = "WRITE_CORE_MEMORY"
|
||||
|
||||
self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}')
|
||||
select.select([self.socket], [], [])
|
||||
response, _ = self.socket.recvfrom(4096)
|
||||
|
||||
splits = response.decode().split(" ", 3)
|
||||
|
||||
assert (splits[0] == command)
|
||||
|
||||
if splits[2] == "-1":
|
||||
logger.info(splits[3])
|
||||
|
||||
|
||||
class LinksAwakeningClient():
|
||||
socket = None
|
||||
gameboy = None
|
||||
tracker = None
|
||||
auth = None
|
||||
game_crc = None
|
||||
pending_deathlink = False
|
||||
deathlink_debounce = True
|
||||
recvd_checks = {}
|
||||
|
||||
def msg(self, m):
|
||||
logger.info(m)
|
||||
s = f"SHOW_MSG {m}\n"
|
||||
self.gameboy.send(s)
|
||||
|
||||
def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355):
|
||||
self.gameboy = RAGameboy(retroarch_address, retroarch_port)
|
||||
|
||||
async def wait_for_retroarch_connection(self):
|
||||
logger.info("Waiting on connection to Retroarch...")
|
||||
while True:
|
||||
try:
|
||||
version = self.gameboy.get_retroarch_version()
|
||||
NO_CONTENT = b"GET_STATUS CONTENTLESS"
|
||||
status = NO_CONTENT
|
||||
core_type = None
|
||||
GAME_BOY = b"game_boy"
|
||||
while status == NO_CONTENT or core_type != GAME_BOY:
|
||||
try:
|
||||
status = self.gameboy.get_retroarch_status(0.1)
|
||||
if status.count(b" ") < 2:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
GET_STATUS, PLAYING, info = status.split(b" ", 2)
|
||||
if status.count(b",") < 2:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
core_type, rom_name, self.game_crc = info.split(b",", 2)
|
||||
if core_type != GAME_BOY:
|
||||
logger.info(
|
||||
f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?")
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
except (BlockingIOError, TimeoutError) as e:
|
||||
await asyncio.sleep(0.1)
|
||||
pass
|
||||
logger.info(f"Connected to Retroarch {version} {info}")
|
||||
self.gameboy.read_memory(0x1000)
|
||||
return
|
||||
except ConnectionResetError:
|
||||
await asyncio.sleep(1.0)
|
||||
pass
|
||||
|
||||
def reset_auth(self):
|
||||
auth = binascii.hexlify(self.gameboy.read_memory(0x0134, 12)).decode()
|
||||
|
||||
if self.auth:
|
||||
assert (auth == self.auth)
|
||||
|
||||
self.auth = auth
|
||||
|
||||
async def wait_and_init_tracker(self):
|
||||
await self.wait_for_game_ready()
|
||||
self.tracker = LocationTracker(self.gameboy)
|
||||
self.item_tracker = ItemTracker(self.gameboy)
|
||||
self.gps_tracker = GpsTracker(self.gameboy)
|
||||
|
||||
async def recved_item_from_ap(self, item_id, from_player, next_index):
|
||||
# Don't allow getting an item until you've got your first check
|
||||
if not self.tracker.has_start_item():
|
||||
return
|
||||
|
||||
# Spin until we either:
|
||||
# get an exception from a bad read (emu shut down or reset)
|
||||
# beat the game
|
||||
# the client handles the last pending item
|
||||
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
||||
while not (await self.is_victory()) and status & 1 == 1:
|
||||
time.sleep(0.1)
|
||||
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
||||
|
||||
item_id -= LABaseID
|
||||
# The player name table only goes up to 100, so don't go past that
|
||||
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
|
||||
if from_player > 100:
|
||||
from_player = 100
|
||||
|
||||
next_index += 1
|
||||
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
|
||||
item_id, from_player])
|
||||
status |= 1
|
||||
status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status])
|
||||
self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index))
|
||||
|
||||
async def wait_for_game_ready(self):
|
||||
logger.info("Waiting on game to be in valid state...")
|
||||
while not await self.gameboy.check_safe_gameplay(throw=False):
|
||||
pass
|
||||
logger.info("Ready!")
|
||||
|
||||
async def is_victory(self):
|
||||
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
|
||||
|
||||
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
|
||||
await self.tracker.readChecks(item_get_cb)
|
||||
await self.item_tracker.readItems()
|
||||
await self.gps_tracker.read_location()
|
||||
|
||||
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
|
||||
if self.deathlink_debounce and current_health != 0:
|
||||
self.deathlink_debounce = False
|
||||
elif not self.deathlink_debounce and current_health == 0:
|
||||
# logger.info("YOU DIED.")
|
||||
await deathlink_cb()
|
||||
self.deathlink_debounce = True
|
||||
|
||||
if self.pending_deathlink:
|
||||
logger.info("Got a deathlink")
|
||||
self.gameboy.write_memory(LAClientConstants.wLinkHealth, [0])
|
||||
self.pending_deathlink = False
|
||||
self.deathlink_debounce = True
|
||||
|
||||
if await self.is_victory():
|
||||
await win_cb()
|
||||
|
||||
recv_index = struct.unpack(">H", self.gameboy.read_memory(LAClientConstants.wRecvIndex, 2))[0]
|
||||
|
||||
# Play back one at a time
|
||||
if recv_index in self.recvd_checks:
|
||||
item = self.recvd_checks[recv_index]
|
||||
await self.recved_item_from_ap(item.item, item.player, recv_index)
|
||||
|
||||
|
||||
all_tasks = set()
|
||||
|
||||
def create_task_log_exception(awaitable) -> asyncio.Task:
|
||||
async def _log_exception(awaitable):
|
||||
try:
|
||||
return await awaitable
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
pass
|
||||
finally:
|
||||
all_tasks.remove(task)
|
||||
task = asyncio.create_task(_log_exception(awaitable))
|
||||
all_tasks.add(task)
|
||||
|
||||
|
||||
class LinksAwakeningContext(CommonContext):
|
||||
tags = {"AP"}
|
||||
game = "Links Awakening DX"
|
||||
items_handling = 0b101
|
||||
want_slot_data = True
|
||||
la_task = None
|
||||
client = None
|
||||
# TODO: does this need to re-read on reset?
|
||||
found_checks = []
|
||||
last_resend = time.time()
|
||||
|
||||
magpie_enabled = False
|
||||
magpie = None
|
||||
magpie_task = None
|
||||
won = False
|
||||
|
||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||
self.client = LinksAwakeningClient()
|
||||
if magpie:
|
||||
self.magpie_enabled = True
|
||||
self.magpie = MagpieBridge()
|
||||
super().__init__(server_address, password)
|
||||
|
||||
def run_gui(self) -> None:
|
||||
import webbrowser
|
||||
import kvui
|
||||
from kvui import Button, GameManager
|
||||
from kivy.uix.image import Image
|
||||
|
||||
class LADXManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("Tracker", "Tracker"),
|
||||
]
|
||||
base_title = "Archipelago Links Awakening DX Client"
|
||||
|
||||
def build(self):
|
||||
b = super().build()
|
||||
|
||||
if self.ctx.magpie_enabled:
|
||||
button = Button(text="", size=(30, 30), size_hint_x=None,
|
||||
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
||||
image = Image(size=(16, 16), texture=magpie_logo())
|
||||
button.add_widget(image)
|
||||
|
||||
def set_center(_, center):
|
||||
image.center = center
|
||||
button.bind(center=set_center)
|
||||
|
||||
self.connect_layout.add_widget(button)
|
||||
return b
|
||||
|
||||
self.ui = LADXManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
async def send_checks(self):
|
||||
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
|
||||
await self.send_msgs(message)
|
||||
|
||||
ENABLE_DEATHLINK = False
|
||||
async def send_deathlink(self):
|
||||
if self.ENABLE_DEATHLINK:
|
||||
message = [{"cmd": 'Deathlink',
|
||||
'time': time.time(),
|
||||
'cause': 'Had a nightmare',
|
||||
# 'source': self.slot_info[self.slot].name,
|
||||
}]
|
||||
await self.send_msgs(message)
|
||||
|
||||
async def send_victory(self):
|
||||
if not self.won:
|
||||
message = [{"cmd": "StatusUpdate",
|
||||
"status": ClientStatus.CLIENT_GOAL}]
|
||||
logger.info("victory!")
|
||||
await self.send_msgs(message)
|
||||
self.won = True
|
||||
|
||||
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||
if self.ENABLE_DEATHLINK:
|
||||
self.client.pending_deathlink = True
|
||||
|
||||
def new_checks(self, item_ids, ladxr_ids):
|
||||
self.found_checks += item_ids
|
||||
create_task_log_exception(self.send_checks())
|
||||
if self.magpie_enabled:
|
||||
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(LinksAwakeningContext, self).server_auth(password_requested)
|
||||
self.auth = self.client.auth
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.slot_info[self.slot].game
|
||||
# TODO - use watcher_event
|
||||
if cmd == "ReceivedItems":
|
||||
for index, item in enumerate(args["items"], args["index"]):
|
||||
self.client.recvd_checks[index] = item
|
||||
|
||||
item_id_lookup = get_locations_to_id()
|
||||
|
||||
async def run_game_loop(self):
|
||||
def on_item_get(ladxr_checks):
|
||||
checks = [self.item_id_lookup[meta_to_name(
|
||||
checkMetadataTable[check.id])] for check in ladxr_checks]
|
||||
self.new_checks(checks, [check.id for check in ladxr_checks])
|
||||
|
||||
async def victory():
|
||||
await self.send_victory()
|
||||
|
||||
async def deathlink():
|
||||
await self.send_deathlink()
|
||||
|
||||
if self.magpie_enabled:
|
||||
self.magpie_task = asyncio.create_task(self.magpie.serve())
|
||||
|
||||
# yield to allow UI to start
|
||||
await asyncio.sleep(0)
|
||||
|
||||
while True:
|
||||
try:
|
||||
# TODO: cancel all client tasks
|
||||
logger.info("(Re)Starting game loop")
|
||||
self.found_checks.clear()
|
||||
await self.client.wait_for_retroarch_connection()
|
||||
self.client.reset_auth()
|
||||
await self.client.wait_and_init_tracker()
|
||||
|
||||
while True:
|
||||
await self.client.main_tick(on_item_get, victory, deathlink)
|
||||
await asyncio.sleep(0.1)
|
||||
now = time.time()
|
||||
if self.last_resend + 5.0 < now:
|
||||
self.last_resend = now
|
||||
await self.send_checks()
|
||||
if self.magpie_enabled:
|
||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
||||
await self.magpie.send_gps(self.client.gps_tracker)
|
||||
|
||||
except GameboyException:
|
||||
time.sleep(1.0)
|
||||
pass
|
||||
|
||||
|
||||
async def main():
|
||||
parser = get_base_parser(description="Link's Awakening Client.")
|
||||
parser.add_argument("--url", help="Archipelago connection url")
|
||||
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
|
||||
|
||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||
help='Path to a .apladx Archipelago Binary Patch file')
|
||||
|
||||
args = parser.parse_args()
|
||||
logger.info(args)
|
||||
|
||||
if args.diff_file:
|
||||
import Patch
|
||||
logger.info("patch file was supplied - creating rom...")
|
||||
meta, rom_file = Patch.create_rom_file(args.diff_file)
|
||||
if "server" in meta:
|
||||
args.url = meta["server"]
|
||||
logger.info(f"wrote rom file to {rom_file}")
|
||||
|
||||
if args.url:
|
||||
url = urllib.parse.urlparse(args.url)
|
||||
args.connect = url.netloc
|
||||
if url.password:
|
||||
args.password = urllib.parse.unquote(url.password)
|
||||
|
||||
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
|
||||
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
|
||||
# TODO: nothing about the lambda about has to be in a lambda
|
||||
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
await ctx.shutdown()
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
1205
LttPAdjuster.py
1205
LttPAdjuster.py
File diff suppressed because it is too large
Load Diff
1055
LttPClient.py
1055
LttPClient.py
File diff suppressed because it is too large
Load Diff
372
MMBN3Client.py
Normal file
372
MMBN3Client.py
Normal file
@@ -0,0 +1,372 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
|
||||
import bsdiff4
|
||||
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
||||
ClientCommandProcessor, logger, get_base_parser
|
||||
import Utils
|
||||
from NetUtils import ClientStatus
|
||||
from worlds.mmbn3.Items import items_by_id
|
||||
from worlds.mmbn3.Rom import get_base_rom_path
|
||||
from worlds.mmbn3.Locations import all_locations, scoutable_locations
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_mmbn3.lua"
|
||||
CONNECTION_REFUSED_STATUS = \
|
||||
"Connection refused. Please start your emulator and make sure connector_mmbn3.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_mmbn3.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
CONNECTION_INCORRECT_ROM = "Supplied Base Rom does not match US GBA Blue Version. Please provide the correct ROM version"
|
||||
|
||||
script_version: int = 2
|
||||
|
||||
debugEnabled = False
|
||||
locations_checked = []
|
||||
items_sent = []
|
||||
itemIndex = 1
|
||||
|
||||
CHECKSUM_BLUE = "6fe31df0144759b34ad666badaacc442"
|
||||
|
||||
|
||||
class MMBN3CommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_gba(self):
|
||||
"""Check GBA Connection State"""
|
||||
if isinstance(self.ctx, MMBN3Context):
|
||||
logger.info(f"GBA Status: {self.ctx.gba_status}")
|
||||
|
||||
def _cmd_debug(self):
|
||||
"""Toggle the Debug Text overlay in ROM"""
|
||||
global debugEnabled
|
||||
debugEnabled = not debugEnabled
|
||||
logger.info("Debug Overlay Enabled" if debugEnabled else "Debug Overlay Disabled")
|
||||
|
||||
|
||||
class MMBN3Context(CommonContext):
|
||||
command_processor = MMBN3CommandProcessor
|
||||
game = "MegaMan Battle Network 3"
|
||||
items_handling = 0b001 # full local
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.gba_streams: (StreamReader, StreamWriter) = None
|
||||
self.gba_sync_task = None
|
||||
self.gba_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.location_table = {}
|
||||
self.version_warning = False
|
||||
self.auth_name = None
|
||||
self.slot_data = dict()
|
||||
self.patching_error = False
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(MMBN3Context, self).server_auth(password_requested)
|
||||
|
||||
if self.auth_name is None:
|
||||
self.awaiting_rom = True
|
||||
logger.info("No ROM detected, awaiting conection to Bizhawk to authenticate to the multiworld server")
|
||||
return
|
||||
|
||||
logger.info("Attempting to decode from ROM... ")
|
||||
self.awaiting_rom = False
|
||||
self.auth = self.auth_name.decode("utf8").replace('\x00', '')
|
||||
logger.info("Connecting as "+self.auth)
|
||||
await self.send_connect(name=self.auth)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class MMBN3Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago MegaMan Battle Network 3 Client"
|
||||
|
||||
self.ui = MMBN3Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.slot_data = args.get("slot_data", {})
|
||||
print(self.slot_data)
|
||||
|
||||
class ItemInfo:
|
||||
id = 0x00
|
||||
sender = ""
|
||||
type = ""
|
||||
count = 1
|
||||
itemName = "Unknown"
|
||||
itemID = 0x00 # Item ID, Chip ID, etc.
|
||||
subItemID = 0x00 # Code for chips, color for programs
|
||||
itemIndex = 1
|
||||
|
||||
def __init__(self, id, sender, type):
|
||||
self.id = id
|
||||
self.sender = sender
|
||||
self.type = type
|
||||
|
||||
def get_json(self):
|
||||
json_data = {
|
||||
"id": self.id,
|
||||
"sender": self.sender,
|
||||
"type": self.type,
|
||||
"itemName": self.itemName,
|
||||
"itemID": self.itemID,
|
||||
"subItemID": self.subItemID,
|
||||
"count": self.count,
|
||||
"itemIndex": self.itemIndex
|
||||
}
|
||||
return json_data
|
||||
|
||||
|
||||
def get_payload(ctx: MMBN3Context):
|
||||
global debugEnabled
|
||||
|
||||
items_sent = []
|
||||
for i, item in enumerate(ctx.items_received):
|
||||
item_data = items_by_id[item.item]
|
||||
new_item = ItemInfo(i, ctx.player_names[item.player], item_data.type)
|
||||
new_item.itemIndex = i+1
|
||||
new_item.itemName = item_data.itemName
|
||||
new_item.type = item_data.type
|
||||
new_item.itemID = item_data.itemID
|
||||
new_item.subItemID = item_data.subItemID
|
||||
new_item.count = item_data.count
|
||||
items_sent.append(new_item)
|
||||
|
||||
return json.dumps({
|
||||
"items": [item.get_json() for item in items_sent],
|
||||
"debug": debugEnabled
|
||||
})
|
||||
|
||||
|
||||
async def parse_payload(payload: dict, ctx: MMBN3Context, force: bool):
|
||||
# Game completion handling
|
||||
if payload["gameComplete"] and not ctx.finished_game:
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "StatusUpdate",
|
||||
"status": ClientStatus.CLIENT_GOAL
|
||||
}])
|
||||
ctx.finished_game = True
|
||||
|
||||
# Locations handling
|
||||
if ctx.location_table != payload["locations"]:
|
||||
ctx.location_table = payload["locations"]
|
||||
locs = [loc.id for loc in all_locations
|
||||
if check_location_packet(loc, ctx.location_table)]
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationChecks",
|
||||
"locations": locs
|
||||
}])
|
||||
|
||||
# If trade hinting is enabled, send scout checks
|
||||
if ctx.slot_data.get("trade_quest_hinting", 0) == 2:
|
||||
scouted_locs = [loc.id for loc in scoutable_locations
|
||||
if check_location_scouted(loc, payload["locations"])]
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationScouts",
|
||||
"locations": scouted_locs,
|
||||
"create_as_hint": 2
|
||||
}])
|
||||
|
||||
|
||||
def check_location_packet(location, memory):
|
||||
if len(memory) == 0:
|
||||
return False
|
||||
# Our keys have to be strings to come through the JSON lua plugin so we have to turn our memory address into a string as well
|
||||
location_key = hex(location.flag_byte)[2:]
|
||||
byte = memory.get(location_key)
|
||||
if byte is not None:
|
||||
return byte & location.flag_mask
|
||||
|
||||
|
||||
def check_location_scouted(location, memory):
|
||||
if len(memory) == 0:
|
||||
return False
|
||||
location_key = hex(location.hint_flag)[2:]
|
||||
byte = memory.get(location_key)
|
||||
if byte is not None:
|
||||
return byte & location.hint_flag_mask
|
||||
|
||||
|
||||
async def gba_sync_task(ctx: MMBN3Context):
|
||||
logger.info("Starting GBA connector. Use /gba for status information.")
|
||||
if ctx.patching_error:
|
||||
logger.error('Unable to Patch ROM. No ROM provided or ROM does not match US GBA Blue Version.')
|
||||
while not ctx.exit_event.is_set():
|
||||
error_status = None
|
||||
if ctx.gba_streams:
|
||||
(reader, writer) = ctx.gba_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 four fields
|
||||
# 1. str: player name (always)
|
||||
# 2. int: script version (always)
|
||||
# 3. dict[str, byte]: value of location's memory byte
|
||||
# 4. bool: whether the game currently registers as complete
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||
data_decoded = json.loads(data.decode())
|
||||
reported_version = data_decoded.get("scriptVersion", 0)
|
||||
if reported_version >= script_version:
|
||||
if ctx.game is not None and "locations" in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task((parse_payload(data_decoded, ctx, False)))
|
||||
if not ctx.auth:
|
||||
ctx.auth_name = bytes(data_decoded["playerName"])
|
||||
|
||||
if ctx.awaiting_rom:
|
||||
logger.info("Awaiting data from ROM...")
|
||||
await ctx.server_auth(False)
|
||||
else:
|
||||
if not ctx.version_warning:
|
||||
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}."
|
||||
"Please update to the latest version."
|
||||
"Your connection to the Archipelago server will not be accepted.")
|
||||
ctx.version_warning = True
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.gba_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.gba_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.gba_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.gba_streams = None
|
||||
if ctx.gba_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to GBA")
|
||||
ctx.gba_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.gba_status = f"Was tentatively connected but error occurred: {error_status}"
|
||||
elif error_status:
|
||||
ctx.gba_status = error_status
|
||||
logger.info("Lost connection to GBA and attempting to reconnect. Use /gba for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to GBA")
|
||||
ctx.gba_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28922), timeout=10)
|
||||
ctx.gba_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.gba_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
options = Utils.get_options().get("mmbn3_options", None)
|
||||
if options is None:
|
||||
auto_start = True
|
||||
else:
|
||||
auto_start = options.get("rom_start", True)
|
||||
if auto_start:
|
||||
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(apmmbn3_file):
|
||||
base_name = os.path.splitext(apmmbn3_file)[0]
|
||||
|
||||
with zipfile.ZipFile(apmmbn3_file, 'r') as patch_archive:
|
||||
try:
|
||||
with patch_archive.open("delta.bsdiff4", 'r') as stream:
|
||||
patch_data = stream.read()
|
||||
except KeyError:
|
||||
raise FileNotFoundError("Patch file missing from archive.")
|
||||
rom_file = get_base_rom_path()
|
||||
|
||||
with open(rom_file, 'rb') as rom:
|
||||
rom_bytes = rom.read()
|
||||
|
||||
patched_bytes = bsdiff4.patch(rom_bytes, patch_data)
|
||||
patched_rom_file = base_name+".gba"
|
||||
with open(patched_rom_file, 'wb') as patched_rom:
|
||||
patched_rom.write(patched_bytes)
|
||||
|
||||
asyncio.create_task(run_game(patched_rom_file))
|
||||
|
||||
|
||||
def confirm_checksum():
|
||||
rom_file = get_base_rom_path()
|
||||
if not os.path.exists(rom_file):
|
||||
return False
|
||||
|
||||
with open(rom_file, 'rb') as rom:
|
||||
rom_bytes = rom.read()
|
||||
|
||||
basemd5 = hashlib.md5()
|
||||
basemd5.update(rom_bytes)
|
||||
return CHECKSUM_BLUE == basemd5.hexdigest()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("MMBN3Client")
|
||||
|
||||
async def main():
|
||||
multiprocessing.freeze_support()
|
||||
parser = get_base_parser()
|
||||
parser.add_argument("patch_file", default="", type=str, nargs="?",
|
||||
help="Path to an APMMBN3 file")
|
||||
args = parser.parse_args()
|
||||
checksum_matches = confirm_checksum()
|
||||
if checksum_matches:
|
||||
if args.patch_file:
|
||||
asyncio.create_task(patch_and_run_game(args.patch_file))
|
||||
|
||||
ctx = MMBN3Context(args.connect, args.password)
|
||||
if not checksum_matches:
|
||||
ctx.patching_error = True
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
ctx.gba_sync_task = asyncio.create_task(gba_sync_task(ctx), name="GBA Sync")
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.gba_sync_task:
|
||||
await ctx.gba_sync_task
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
344
MinecraftClient.py
Normal file
344
MinecraftClient.py
Normal file
@@ -0,0 +1,344 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import atexit
|
||||
import shutil
|
||||
from subprocess import Popen
|
||||
from shutil import copyfile
|
||||
from time import strftime
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
import Utils
|
||||
from Utils import is_windows
|
||||
|
||||
atexit.register(input, "Press enter to exit.")
|
||||
|
||||
# 1 or more digits followed by m or g, then optional b
|
||||
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
|
||||
|
||||
|
||||
def prompt_yes_no(prompt):
|
||||
yes_inputs = {'yes', 'ye', 'y'}
|
||||
no_inputs = {'no', 'n'}
|
||||
while True:
|
||||
choice = input(prompt + " [y/n] ").lower()
|
||||
if choice in yes_inputs:
|
||||
return True
|
||||
elif choice in no_inputs:
|
||||
return False
|
||||
else:
|
||||
print('Please respond with "y" or "n".')
|
||||
|
||||
|
||||
def find_ap_randomizer_jar(forge_dir):
|
||||
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
|
||||
mods_dir = os.path.join(forge_dir, 'mods')
|
||||
if os.path.isdir(mods_dir):
|
||||
for entry in os.scandir(mods_dir):
|
||||
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
|
||||
logging.info(f"Found AP randomizer mod: {entry.name}")
|
||||
return entry.name
|
||||
return None
|
||||
else:
|
||||
os.mkdir(mods_dir)
|
||||
logging.info(f"Created mods folder in {forge_dir}")
|
||||
return None
|
||||
|
||||
|
||||
def replace_apmc_files(forge_dir, apmc_file):
|
||||
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
|
||||
if apmc_file is None:
|
||||
return
|
||||
apdata_dir = os.path.join(forge_dir, 'APData')
|
||||
copy_apmc = True
|
||||
if not os.path.isdir(apdata_dir):
|
||||
os.mkdir(apdata_dir)
|
||||
logging.info(f"Created APData folder in {forge_dir}")
|
||||
for entry in os.scandir(apdata_dir):
|
||||
if entry.name.endswith(".apmc") and entry.is_file():
|
||||
if not os.path.samefile(apmc_file, entry.path):
|
||||
os.remove(entry.path)
|
||||
logging.info(f"Removed {entry.name} in {apdata_dir}")
|
||||
else: # apmc already in apdata
|
||||
copy_apmc = False
|
||||
if copy_apmc:
|
||||
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
|
||||
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
|
||||
|
||||
|
||||
def read_apmc_file(apmc_file):
|
||||
from base64 import b64decode
|
||||
|
||||
with open(apmc_file, 'r') as f:
|
||||
return json.loads(b64decode(f.read()))
|
||||
|
||||
|
||||
def update_mod(forge_dir, url: str):
|
||||
"""Check mod version, download new mod from GitHub releases page if needed. """
|
||||
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
||||
os.path.basename(url)
|
||||
if ap_randomizer is not None:
|
||||
logging.info(f"Your current mod is {ap_randomizer}.")
|
||||
else:
|
||||
logging.info(f"You do not have the AP randomizer mod installed.")
|
||||
|
||||
if ap_randomizer != os.path.basename(url):
|
||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
||||
f"{os.path.basename(url)}")
|
||||
if prompt_yes_no("Would you like to update?"):
|
||||
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
|
||||
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
|
||||
logging.info("Downloading AP randomizer mod. This may take a moment...")
|
||||
apmod_resp = requests.get(url)
|
||||
if apmod_resp.status_code == 200:
|
||||
with open(new_ap_mod, 'wb') as f:
|
||||
f.write(apmod_resp.content)
|
||||
logging.info(f"Wrote new mod file to {new_ap_mod}")
|
||||
if old_ap_mod is not None:
|
||||
os.remove(old_ap_mod)
|
||||
logging.info(f"Removed old mod file from {old_ap_mod}")
|
||||
else:
|
||||
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
||||
logging.error(f"Please report this issue on the Archipelago Discord server.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def check_eula(forge_dir):
|
||||
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
|
||||
eula_path = os.path.join(forge_dir, "eula.txt")
|
||||
if not os.path.isfile(eula_path):
|
||||
# Create eula.txt
|
||||
with open(eula_path, 'w') as f:
|
||||
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
|
||||
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
|
||||
f.write("eula=false\n")
|
||||
with open(eula_path, 'r+') as f:
|
||||
text = f.read()
|
||||
if 'false' in text:
|
||||
# Prompt user to agree to the EULA
|
||||
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
|
||||
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
|
||||
if prompt_yes_no("Do you agree to the EULA?"):
|
||||
f.seek(0)
|
||||
f.write(text.replace('false', 'true'))
|
||||
f.truncate()
|
||||
logging.info(f"Set {eula_path} to true")
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def find_jdk_dir(version: str) -> str:
|
||||
"""get the specified versions jdk directory"""
|
||||
for entry in os.listdir():
|
||||
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
|
||||
return os.path.abspath(entry)
|
||||
|
||||
|
||||
def find_jdk(version: str) -> str:
|
||||
"""get the java exe location"""
|
||||
|
||||
if is_windows:
|
||||
jdk = find_jdk_dir(version)
|
||||
jdk_exe = os.path.join(jdk, "bin", "java.exe")
|
||||
if os.path.isfile(jdk_exe):
|
||||
return jdk_exe
|
||||
else:
|
||||
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
|
||||
if not jdk_exe:
|
||||
raise Exception("Could not find Java. Is Java installed on the system?")
|
||||
return jdk_exe
|
||||
|
||||
|
||||
def download_java(java: str):
|
||||
"""Download Corretto (Amazon JDK)"""
|
||||
|
||||
jdk = find_jdk_dir(java)
|
||||
if jdk is not None:
|
||||
print(f"Removing old JDK...")
|
||||
from shutil import rmtree
|
||||
rmtree(jdk)
|
||||
|
||||
print(f"Downloading Java...")
|
||||
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
|
||||
resp = requests.get(jdk_url)
|
||||
if resp.status_code == 200: # OK
|
||||
print(f"Extracting...")
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
|
||||
zf.extractall()
|
||||
else:
|
||||
print(f"Error downloading Java (status code {resp.status_code}).")
|
||||
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
|
||||
if not prompt_yes_no("Continue anyways?"):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def install_forge(directory: str, forge_version: str, java_version: str):
|
||||
"""download and install forge"""
|
||||
|
||||
java_exe = find_jdk(java_version)
|
||||
if java_exe is not None:
|
||||
print(f"Downloading Forge {forge_version}...")
|
||||
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
|
||||
resp = requests.get(forge_url)
|
||||
if resp.status_code == 200: # OK
|
||||
forge_install_jar = os.path.join(directory, "forge_install.jar")
|
||||
if not os.path.exists(directory):
|
||||
os.mkdir(directory)
|
||||
with open(forge_install_jar, 'wb') as f:
|
||||
f.write(resp.content)
|
||||
print(f"Installing Forge...")
|
||||
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
|
||||
install_process.wait()
|
||||
os.remove(forge_install_jar)
|
||||
|
||||
|
||||
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
|
||||
"""Run the Forge server."""
|
||||
|
||||
java_exe = find_jdk(java_version)
|
||||
if not os.path.isfile(java_exe):
|
||||
java_exe = "java" # try to fall back on java in the PATH
|
||||
|
||||
heap_arg = max_heap_re.match(heap_arg).group()
|
||||
if heap_arg[-1] in ['b', 'B']:
|
||||
heap_arg = heap_arg[:-1]
|
||||
heap_arg = "-Xmx" + heap_arg
|
||||
|
||||
os_args = "win_args.txt" if is_windows else "unix_args.txt"
|
||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
|
||||
forge_args = []
|
||||
with open(args_file) as argfile:
|
||||
for line in argfile:
|
||||
forge_args.extend(line.strip().split(" "))
|
||||
|
||||
args = [java_exe, heap_arg, *forge_args, "-nogui"]
|
||||
logging.info(f"Running Forge server: {args}")
|
||||
os.chdir(forge_dir)
|
||||
return Popen(args)
|
||||
|
||||
|
||||
def get_minecraft_versions(version, release_channel="release"):
|
||||
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
|
||||
resp = requests.get(version_file_endpoint)
|
||||
local = False
|
||||
if resp.status_code == 200: # OK
|
||||
try:
|
||||
data = resp.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
||||
local = True
|
||||
else:
|
||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
||||
local = True
|
||||
|
||||
if local:
|
||||
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
|
||||
json.dump(data, f)
|
||||
|
||||
try:
|
||||
if version:
|
||||
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
||||
else:
|
||||
return resp.json()[release_channel][0]
|
||||
except (StopIteration, KeyError):
|
||||
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
|
||||
if release_channel != "release":
|
||||
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
|
||||
else:
|
||||
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def is_correct_forge(forge_dir) -> bool:
|
||||
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
Utils.init_logging("MinecraftClient")
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
|
||||
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
|
||||
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
|
||||
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
|
||||
help="Specify release channel to use.")
|
||||
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
|
||||
help="specify java version.")
|
||||
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
|
||||
help="specify forge version. (Minecraft Version-Forge Version)")
|
||||
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
|
||||
help="specify Mod data version to download.")
|
||||
|
||||
args = parser.parse_args()
|
||||
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
|
||||
|
||||
# Change to executable's working directory
|
||||
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
|
||||
|
||||
options = Utils.get_options()
|
||||
channel = args.channel or options["minecraft_options"]["release_channel"]
|
||||
apmc_data = None
|
||||
data_version = args.data_version or None
|
||||
|
||||
if apmc_file is None and not args.install:
|
||||
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
|
||||
|
||||
if apmc_file is not None and data_version is None:
|
||||
apmc_data = read_apmc_file(apmc_file)
|
||||
data_version = apmc_data.get('client_version', '')
|
||||
|
||||
versions = get_minecraft_versions(data_version, channel)
|
||||
|
||||
forge_dir = Utils.user_path(options["minecraft_options"]["forge_directory"])
|
||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
||||
forge_version = args.forge or versions["forge"]
|
||||
java_version = args.java or versions["java"]
|
||||
mod_url = versions["url"]
|
||||
java_dir = find_jdk_dir(java_version)
|
||||
|
||||
if args.install:
|
||||
if is_windows:
|
||||
print("Installing Java")
|
||||
download_java(java_version)
|
||||
if not is_correct_forge(forge_dir):
|
||||
print("Installing Minecraft Forge")
|
||||
install_forge(forge_dir, forge_version, java_version)
|
||||
else:
|
||||
print("Correct Forge version already found, skipping install.")
|
||||
sys.exit(0)
|
||||
|
||||
if apmc_data is None:
|
||||
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
|
||||
|
||||
if is_windows:
|
||||
if java_dir is None or not os.path.isdir(java_dir):
|
||||
if prompt_yes_no("Did not find java directory. Download and install java now?"):
|
||||
download_java(java_version)
|
||||
java_dir = find_jdk_dir(java_version)
|
||||
if java_dir is None or not os.path.isdir(java_dir):
|
||||
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
|
||||
|
||||
if not is_correct_forge(forge_dir):
|
||||
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
|
||||
install_forge(forge_dir, forge_version, java_version)
|
||||
if not os.path.isdir(forge_dir):
|
||||
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
|
||||
|
||||
if not max_heap_re.match(max_heap):
|
||||
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
|
||||
|
||||
update_mod(forge_dir, mod_url)
|
||||
replace_apmc_files(forge_dir, apmc_file)
|
||||
check_eula(forge_dir)
|
||||
server_process = run_forge_server(forge_dir, java_version, max_heap)
|
||||
server_process.wait()
|
||||
141
ModuleUpdate.py
141
ModuleUpdate.py
@@ -1,56 +1,131 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import importlib
|
||||
import multiprocessing
|
||||
import warnings
|
||||
|
||||
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.")
|
||||
|
||||
update_ran = hasattr(sys, "frozen") and getattr(sys, "frozen") # don't run update if environment is frozen/compiled
|
||||
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
||||
update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process()
|
||||
|
||||
if not update_ran:
|
||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||
# skip .* (hidden / disabled) folders
|
||||
if not entry.name.startswith("."):
|
||||
if entry.is_dir():
|
||||
req_file = os.path.join(entry.path, "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
requirements_files.add(req_file)
|
||||
|
||||
|
||||
def check_pip():
|
||||
# detect if pip is available
|
||||
try:
|
||||
import pip # noqa: F401
|
||||
except ImportError:
|
||||
raise RuntimeError("pip not available. Please install pip.")
|
||||
|
||||
|
||||
def confirm(msg: str):
|
||||
try:
|
||||
input(f"\n{msg}")
|
||||
except KeyboardInterrupt:
|
||||
print("\nAborting")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def update_command():
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', 'requirements.txt', '--upgrade'])
|
||||
check_pip()
|
||||
for file in requirements_files:
|
||||
subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--upgrade"])
|
||||
|
||||
|
||||
naming_specialties = {"PyYAML": "yaml", # PyYAML is imported as the name yaml
|
||||
"maseya-z3pr": "maseya",
|
||||
"factorio-rcon-py": "factorio_rcon"}
|
||||
def install_pkg_resources(yes=False):
|
||||
try:
|
||||
import pkg_resources # noqa: F401
|
||||
except ImportError:
|
||||
check_pip()
|
||||
if not yes:
|
||||
confirm("pkg_resources not found, press enter to install it")
|
||||
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
|
||||
|
||||
|
||||
def update():
|
||||
def update(yes=False, force=False):
|
||||
global update_ran
|
||||
if not update_ran:
|
||||
update_ran = True
|
||||
path = os.path.join(os.path.dirname(sys.argv[0]), 'requirements.txt')
|
||||
if not os.path.exists(path):
|
||||
path = os.path.join(os.path.dirname(__file__), 'requirements.txt')
|
||||
with open(path) as requirementsfile:
|
||||
for line in requirementsfile.readlines():
|
||||
module, remote_version = line.split(">=")
|
||||
module = naming_specialties.get(module, module)
|
||||
try:
|
||||
module = importlib.import_module(module)
|
||||
except:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
input(f'Required python module {module} not found, press enter to install it')
|
||||
update_command()
|
||||
return
|
||||
else:
|
||||
if hasattr(module, "__version__"):
|
||||
module_version = module.__version__
|
||||
module = module.__name__ # also unloads the module to make it writable
|
||||
if type(module_version) == str:
|
||||
module_version = tuple(int(part.strip()) for part in module_version.split("."))
|
||||
remote_version = tuple(int(part.strip()) for part in remote_version.split("."))
|
||||
if module_version < remote_version:
|
||||
input(f'Required python module {module} is outdated ({module_version}<{remote_version}),'
|
||||
' press enter to upgrade it')
|
||||
|
||||
if force:
|
||||
update_command()
|
||||
return
|
||||
|
||||
install_pkg_resources(yes=yes)
|
||||
import pkg_resources
|
||||
|
||||
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:
|
||||
for line in requirementsfile:
|
||||
if not line or line[0] == "#":
|
||||
continue # ignore comments
|
||||
if line.startswith(("https://", "git+https://")):
|
||||
# extract name and version for url
|
||||
rest = line.split('/')[-1]
|
||||
line = ""
|
||||
if "#egg=" in rest:
|
||||
# from egg info
|
||||
rest, egg = rest.split("#egg=", 1)
|
||||
egg = egg.split(";", 1)[0].rstrip()
|
||||
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
|
||||
warnings.warn(f"Specifying version as #egg={egg} will become unavailable in pip 25.0. "
|
||||
"Use name @ url#version instead.", DeprecationWarning)
|
||||
line = egg
|
||||
else:
|
||||
egg = ""
|
||||
if "@" in rest and not line:
|
||||
raise ValueError("Can't deduce version from requirement")
|
||||
elif not line:
|
||||
# from filename
|
||||
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
|
||||
name, version, _ = rest.split("-", 2)
|
||||
line = f'{egg or name}=={version}'
|
||||
elif "@" in line and "#" in line:
|
||||
# PEP 508 does not allow us to specify a version, so we use custom syntax
|
||||
# name @ url#version ; marker
|
||||
name, rest = line.split("@", 1)
|
||||
version = rest.split("#", 1)[1].split(";", 1)[0].rstrip()
|
||||
line = f"{name.rstrip()}=={version}"
|
||||
if ";" in rest: # keep marker
|
||||
line += rest[rest.find(";"):]
|
||||
requirements = pkg_resources.parse_requirements(line)
|
||||
for requirement in map(str, requirements):
|
||||
try:
|
||||
pkg_resources.require(requirement)
|
||||
except pkg_resources.ResolutionError:
|
||||
if not yes:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
confirm(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')
|
||||
parser.add_argument('-a', '--append', nargs="*", dest='additional_requirements',
|
||||
help='List paths to additional requirement files.')
|
||||
args = parser.parse_args()
|
||||
if args.additional_requirements:
|
||||
requirements_files.update(args.additional_requirements)
|
||||
update(args.yes, args.force)
|
||||
|
||||
219
MultiMystery.py
219
MultiMystery.py
@@ -1,219 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
|
||||
|
||||
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
|
||||
from Patch import create_patch_file
|
||||
|
||||
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_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
|
||||
player_name = multi_mystery_options["player_name"]
|
||||
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
|
||||
else:
|
||||
basemysterycommand = f"py -{py_version} Mystery.py" # source
|
||||
|
||||
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 player_name:
|
||||
for file in os.listdir(output_path):
|
||||
if player_name in file:
|
||||
import MultiClient
|
||||
import asyncio
|
||||
|
||||
asyncio.run(MultiClient.run_game(os.path.join(output_path, file)))
|
||||
break
|
||||
|
||||
if any((zip_roms, zip_multidata, zip_spoiler, zip_diffs)):
|
||||
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 and player_name.lower() not in file.lower():
|
||||
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)
|
||||
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
futures = []
|
||||
with zipfile.ZipFile(zipname, "w", compression=compression, compresslevel=9) as zf:
|
||||
for file in os.listdir(output_path):
|
||||
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))
|
||||
|
||||
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
|
||||
else:
|
||||
baseservercommand = f"py -{py_version} MultiServer.py" # source
|
||||
# don't have a mac to test that. If you try to run compiled on mac, good luck.
|
||||
|
||||
subprocess.call(f"{baseservercommand} --multidata {os.path.join(output_path, multidataname)}")
|
||||
except:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
input("Press enter to close")
|
||||
1992
MultiServer.py
1992
MultiServer.py
File diff suppressed because it is too large
Load Diff
826
Mystery.py
826
Mystery.py
@@ -1,826 +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.generic import PlandoItem, PlandoConnection
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
from Utils import parse_yaml
|
||||
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
|
||||
|
||||
|
||||
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] = get_weights(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] = get_weights(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] = get_weights(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 get_weights(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 '')))
|
||||
return new_name.strip().replace(' ', '_')[:16]
|
||||
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
# remove sometime before 1.0.0, warn before
|
||||
legacy_boss_shuffle_options = {
|
||||
# legacy, will go away:
|
||||
'simple': 'basic',
|
||||
'random': 'full',
|
||||
'normal': 'full'
|
||||
}
|
||||
|
||||
legacy_goals = {
|
||||
'dungeons': 'bosses',
|
||||
'fast_ganon': 'crystals',
|
||||
}
|
||||
|
||||
|
||||
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.")
|
||||
if "options" in option_set:
|
||||
weights = update_weights(weights, option_set["options"], "Linked", option_set["name"])
|
||||
if "rom_options" in option_set:
|
||||
rom_weights = weights.get("rom", dict())
|
||||
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Linked Rom",
|
||||
option_set["name"])
|
||||
weights["rom"] = rom_weights
|
||||
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:
|
||||
key = get_choice("option_name", option_set)
|
||||
if key not in 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, weights)
|
||||
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
|
||||
if "options" in option_set:
|
||||
weights = update_weights(weights, option_set["options"], "Triggered", option_set["option_name"])
|
||||
|
||||
if "rom_options" in option_set:
|
||||
rom_weights = weights.get("rom", dict())
|
||||
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Triggered Rom",
|
||||
option_set["option_name"])
|
||||
weights["rom"] = rom_weights
|
||||
weights[key] = result
|
||||
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 legacy_boss_shuffle_options:
|
||||
new_boss_shuffle = legacy_boss_shuffle_options[boss_shuffle]
|
||||
logging.warning(f"Boss shuffle {boss_shuffle} is deprecated, "
|
||||
f"please use {new_boss_shuffle} instead")
|
||||
return new_boss_shuffle
|
||||
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 legacy_boss_shuffle_options:
|
||||
remainder_shuffle = legacy_boss_shuffle_options[boss_shuffle]
|
||||
logging.warning(f"Boss shuffle {boss} is deprecated, "
|
||||
f"please use {remainder_shuffle} instead")
|
||||
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)
|
||||
|
||||
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, "A Link to the Past")
|
||||
|
||||
ret.local_items = set()
|
||||
for item_name in 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 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 = weights.get('startinventory', {})
|
||||
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
|
||||
|
||||
if ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, weights, plando_options)
|
||||
elif ret.game == "Hollow Knight":
|
||||
for option_name, option in Options.hollow_knight_options.items():
|
||||
setattr(ret, option_name, option.from_any(get_choice(option_name, weights, True)))
|
||||
elif ret.game == "Factorio":
|
||||
for option_name, option in Options.factorio_options.items():
|
||||
if option_name in weights:
|
||||
if issubclass(option, Options.OptionDict): # get_choice should probably land in the Option class
|
||||
setattr(ret, option_name, option.from_any(weights[option_name]))
|
||||
else:
|
||||
setattr(ret, option_name, option.from_any(get_choice(option_name, weights)))
|
||||
else:
|
||||
setattr(ret, option_name, option(option.default))
|
||||
elif ret.game == "Minecraft":
|
||||
for option_name, option in Options.minecraft_options.items():
|
||||
if option_name in weights:
|
||||
setattr(ret, option_name, option.from_any(get_choice(option_name, weights)))
|
||||
else:
|
||||
setattr(ret, option_name, option(option.default))
|
||||
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', 'minor_glitches']:
|
||||
logging.warning("Only NMG, OWG and No Logic supported")
|
||||
glitches_required = 'none'
|
||||
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
|
||||
'minor_glitches': 'minorglitches'}[
|
||||
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')
|
||||
|
||||
if goal in legacy_goals:
|
||||
logging.warning(f"Goal {goal} is depcrecated, please use {legacy_goals[goal]} instead.")
|
||||
goal = legacy_goals[goal]
|
||||
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')
|
||||
|
||||
ret.crystals_gt = prefer_int(get_choice('tower_open', weights))
|
||||
|
||||
ret.crystals_ganon = prefer_int(get_choice('ganon_open', weights))
|
||||
|
||||
extra_pieces = get_choice('triforce_pieces_mode', weights, 'available')
|
||||
|
||||
ret.triforce_pieces_required = int(get_choice('triforce_pieces_required', weights, 20))
|
||||
ret.triforce_pieces_required = min(max(1, int(ret.triforce_pieces_required)), 90)
|
||||
|
||||
# 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 = int(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)
|
||||
shuffle_slots = get_choice('shop_shuffle_slots', weights, '0')
|
||||
if str(shuffle_slots).lower() == "random":
|
||||
ret.shop_shuffle_slots = random.randint(0, 30)
|
||||
else:
|
||||
ret.shop_shuffle_slots = int(shuffle_slots)
|
||||
|
||||
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")
|
||||
))
|
||||
|
||||
if 'rom' in weights:
|
||||
romweights = weights['rom']
|
||||
|
||||
ret.sprite_pool = romweights['sprite_pool'] if 'sprite_pool' in romweights else []
|
||||
ret.sprite = get_choice('sprite', romweights, "Link")
|
||||
if 'random_sprite_on_event' in romweights:
|
||||
randomoneventweights = romweights['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 romweights: # Use sprite as a weighted sprite pool, if a sprite pool is not already defined.
|
||||
for key, value in romweights['sprite'].items():
|
||||
if key.startswith('random'):
|
||||
ret.sprite_pool += ['random'] * int(value)
|
||||
else:
|
||||
ret.sprite_pool += [key] * int(value)
|
||||
|
||||
ret.disablemusic = get_choice('disablemusic', romweights, False)
|
||||
ret.triforcehud = get_choice('triforcehud', romweights, 'hide_goal')
|
||||
ret.quickswap = get_choice('quickswap', romweights, True)
|
||||
ret.fastmenu = get_choice('menuspeed', romweights, "normal")
|
||||
ret.reduceflashing = get_choice('reduceflashing', romweights, False)
|
||||
ret.heartcolor = get_choice('heartcolor', romweights, "red")
|
||||
ret.heartbeep = convert_to_on_off(get_choice('heartbeep', romweights, "normal"))
|
||||
ret.ow_palettes = get_choice('ow_palettes', romweights, "default")
|
||||
ret.uw_palettes = get_choice('uw_palettes', romweights, "default")
|
||||
ret.hud_palettes = get_choice('hud_palettes', romweights, "default")
|
||||
ret.sword_palettes = get_choice('sword_palettes', romweights, "default")
|
||||
ret.shield_palettes = get_choice('shield_palettes', romweights, "default")
|
||||
ret.link_palettes = get_choice('link_palettes', romweights, "default")
|
||||
|
||||
else:
|
||||
ret.quickswap = True
|
||||
ret.sprite = "Link"
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
199
NetUtils.py
199
NetUtils.py
@@ -1,13 +1,12 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import typing
|
||||
import enum
|
||||
from json import JSONEncoder, JSONDecoder
|
||||
|
||||
import websockets
|
||||
|
||||
from Utils import Version
|
||||
from Utils import ByValue, Version
|
||||
|
||||
|
||||
class JSONMessagePart(typing.TypedDict, total=False):
|
||||
@@ -15,11 +14,13 @@ 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):
|
||||
class ClientStatus(ByValue, enum.IntEnum):
|
||||
CLIENT_UNKNOWN = 0
|
||||
CLIENT_CONNECTED = 5
|
||||
CLIENT_READY = 10
|
||||
@@ -27,17 +28,57 @@ class ClientStatus(enum.IntEnum):
|
||||
CLIENT_GOAL = 30
|
||||
|
||||
|
||||
class SlotType(ByValue, enum.IntFlag):
|
||||
spectator = 0b00
|
||||
player = 0b01
|
||||
group = 0b10
|
||||
|
||||
@property
|
||||
def always_goal(self) -> bool:
|
||||
"""Mark this slot as having reached its goal instantly."""
|
||||
return self.value != 0b01
|
||||
|
||||
|
||||
class Permission(ByValue, 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 release
|
||||
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, frozenset)):
|
||||
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()}
|
||||
@@ -55,10 +96,11 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
_encode = JSONEncoder(
|
||||
ensure_ascii=False,
|
||||
check_circular=False,
|
||||
separators=(',', ':'),
|
||||
).encode
|
||||
|
||||
|
||||
def encode(obj):
|
||||
def encode(obj: typing.Any) -> str:
|
||||
return _encode(_scan_for_TypedTuples(obj))
|
||||
|
||||
|
||||
@@ -67,9 +109,11 @@ def get_any_version(data: dict) -> Version:
|
||||
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
|
||||
|
||||
|
||||
whitelist = {"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
}
|
||||
allowlist = {
|
||||
"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
"NetworkSlot": NetworkSlot
|
||||
}
|
||||
|
||||
custom_hooks = {
|
||||
"Version": get_any_version
|
||||
@@ -81,7 +125,7 @@ def _object_hook(o: typing.Any) -> typing.Any:
|
||||
hook = custom_hooks.get(o.get("class", None), None)
|
||||
if hook:
|
||||
return hook(o)
|
||||
cls = whitelist.get(o.get("class", None), None)
|
||||
cls = allowlist.get(o.get("class", None), None)
|
||||
if cls:
|
||||
for key in tuple(o):
|
||||
if key not in cls._fields:
|
||||
@@ -94,60 +138,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 +162,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 +185,21 @@ class JSONTypes(str, enum.Enum):
|
||||
|
||||
|
||||
class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
color_codes = {
|
||||
# not exact color names, close enough but decent looking
|
||||
"black": "000000",
|
||||
"red": "EE0000",
|
||||
"green": "00FF7F",
|
||||
"yellow": "FAFAD2",
|
||||
"blue": "6495ED",
|
||||
"magenta": "EE00EE",
|
||||
"cyan": "00EEEE",
|
||||
"slateblue": "6D8BE8",
|
||||
"plum": "AF99EF",
|
||||
"salmon": "FA8072",
|
||||
"white": "FFFFFF"
|
||||
}
|
||||
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
|
||||
@@ -196,13 +207,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,29 +231,35 @@ 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: # useful
|
||||
node["color"] = 'slateblue'
|
||||
elif flags & 0b100: # trap
|
||||
node["color"] = 'salmon'
|
||||
else:
|
||||
node["color"] = 'cyan'
|
||||
return self._handle_color(node)
|
||||
|
||||
def _handle_item_id(self, node: JSONMessagePart):
|
||||
item_id = int(node["text"])
|
||||
node["text"] = self.ctx.item_name_getter(item_id)
|
||||
node["text"] = self.ctx.item_names[item_id]
|
||||
return self._handle_item_name(node)
|
||||
|
||||
def _handle_location_name(self, node: JSONMessagePart):
|
||||
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)
|
||||
node["text"] = self.ctx.location_names[item_id]
|
||||
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)
|
||||
|
||||
|
||||
@@ -253,7 +270,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
|
||||
|
||||
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
|
||||
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
|
||||
'blue_bg': 44, 'purple_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
|
||||
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
|
||||
|
||||
|
||||
def color_code(*args):
|
||||
@@ -268,6 +285,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 +300,15 @@ class Hint(typing.NamedTuple):
|
||||
item: int
|
||||
found: bool
|
||||
entrance: str = ""
|
||||
item_flags: int = 0
|
||||
|
||||
def re_check(self, ctx, team) -> Hint:
|
||||
if self.found:
|
||||
return self
|
||||
found = self.location in ctx.location_checks[team, self.finding_player]
|
||||
if found:
|
||||
return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance)
|
||||
return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance,
|
||||
self.item_flags)
|
||||
return self
|
||||
|
||||
def __hash__(self):
|
||||
@@ -292,9 +319,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,9 +329,17 @@ 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"}
|
||||
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
|
||||
"receiving": self.receiving_player,
|
||||
"item": NetworkItem(self.item, self.location, self.finding_player, self.item_flags),
|
||||
"found": self.found}
|
||||
|
||||
@property
|
||||
def local(self):
|
||||
return self.receiving_player == self.finding_player
|
||||
|
||||
252
OoTAdjuster.py
Normal file
252
OoTAdjuster.py
Normal file
@@ -0,0 +1,252 @@
|
||||
import tkinter as tk
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
import zipfile
|
||||
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 Utils 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.per_slot_randoms = {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] in ['.apz5', '.zpf']:
|
||||
# Load vanilla ROM
|
||||
rom = Rom(file=args.vanilla_rom, force_use=True)
|
||||
apz5_file = args.rom
|
||||
base_name = os.path.splitext(apz5_file)[0]
|
||||
# Patch file
|
||||
apply_patch_file(rom, apz5_file,
|
||||
sub_file=(os.path.basename(base_name) + '.zpf'
|
||||
if zipfile.is_zipfile(apz5_file)
|
||||
else None))
|
||||
else:
|
||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5, .zpf")
|
||||
# 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()
|
||||
354
OoTClient.py
Normal file
354
OoTClient.py
Normal file
@@ -0,0 +1,354 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
import zipfile
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
||||
ClientCommandProcessor, logger, get_base_parser
|
||||
import Utils
|
||||
from Utils import async_start
|
||||
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 connector_oot.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure connector_oot.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_oot.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
"""
|
||||
Payload: lua -> client
|
||||
{
|
||||
playerName: string,
|
||||
locations: dict,
|
||||
deathlinkActive: bool,
|
||||
isDead: bool,
|
||||
gameComplete: bool
|
||||
}
|
||||
|
||||
Payload: client -> lua
|
||||
{
|
||||
items: list,
|
||||
playerNames: list,
|
||||
triggerDeath: bool
|
||||
}
|
||||
|
||||
Deathlink logic:
|
||||
"Dead" is true <-> Link is at 0 hp.
|
||||
|
||||
deathlink_pending: we need to kill the player
|
||||
deathlink_sent_this_death: we interacted with the multiworld on this death, waiting to reset with living link
|
||||
|
||||
"""
|
||||
|
||||
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
||||
|
||||
script_version: int = 3
|
||||
|
||||
def get_item_value(ap_id):
|
||||
return ap_id - 66000
|
||||
|
||||
|
||||
class OoTCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_n64(self):
|
||||
"""Check N64 Connection State"""
|
||||
if isinstance(self.ctx, OoTContext):
|
||||
logger.info(f"N64 Status: {self.ctx.n64_status}")
|
||||
|
||||
def _cmd_deathlink(self):
|
||||
"""Toggle deathlink from client. Overrides default setting."""
|
||||
if isinstance(self.ctx, OoTContext):
|
||||
self.ctx.deathlink_client_override = True
|
||||
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
||||
async_start(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
||||
|
||||
|
||||
class OoTContext(CommonContext):
|
||||
command_processor = OoTCommandProcessor
|
||||
items_handling = 0b001 # full local
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.game = 'Ocarina of Time'
|
||||
self.n64_streams: (StreamReader, StreamWriter) = None
|
||||
self.n64_sync_task = None
|
||||
self.n64_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.location_table = {}
|
||||
self.collectible_table = {}
|
||||
self.collectible_override_flags_address = 0
|
||||
self.collectible_offsets = {}
|
||||
self.deathlink_enabled = False
|
||||
self.deathlink_pending = False
|
||||
self.deathlink_sent_this_death = False
|
||||
self.deathlink_client_override = False
|
||||
self.version_warning = False
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(OoTContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to EmuHawk to get player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
self.deathlink_pending = True
|
||||
super().on_deathlink(data)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class OoTManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Ocarina of Time Client"
|
||||
|
||||
self.ui = OoTManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_package(self, cmd, args):
|
||||
if cmd == 'Connected':
|
||||
slot_data = args.get('slot_data', None)
|
||||
if slot_data:
|
||||
self.collectible_override_flags_address = slot_data.get('collectible_override_flags', 0)
|
||||
self.collectible_offsets = slot_data.get('collectible_flag_offsets', {})
|
||||
|
||||
|
||||
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
|
||||
|
||||
payload = 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,
|
||||
"collectibleOverrides": ctx.collectible_override_flags_address,
|
||||
"collectibleOffsets": ctx.collectible_offsets
|
||||
})
|
||||
return payload
|
||||
|
||||
|
||||
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
|
||||
# Refuse to do anything if ROM is detected as changed
|
||||
if ctx.auth and payload['playerName'] != ctx.auth:
|
||||
logger.warning("ROM change detected. Disconnecting and reconnecting...")
|
||||
ctx.deathlink_enabled = False
|
||||
ctx.deathlink_client_override = False
|
||||
ctx.finished_game = False
|
||||
ctx.location_table = {}
|
||||
ctx.collectible_table = {}
|
||||
ctx.deathlink_pending = False
|
||||
ctx.deathlink_sent_this_death = False
|
||||
ctx.auth = payload['playerName']
|
||||
await ctx.send_connect()
|
||||
return
|
||||
|
||||
# Turn on deathlink if it is on, and if the client hasn't overriden it
|
||||
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
|
||||
await ctx.update_death_link(True)
|
||||
ctx.deathlink_enabled = True
|
||||
|
||||
# Game completion handling
|
||||
if payload['gameComplete'] and not ctx.finished_game:
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "StatusUpdate",
|
||||
"status": 30
|
||||
}])
|
||||
ctx.finished_game = True
|
||||
|
||||
# Locations handling
|
||||
locations = payload['locations']
|
||||
collectibles = payload['collectibles']
|
||||
|
||||
# The Lua JSON library serializes an empty table into a list instead of a dict. Verify types for safety:
|
||||
if isinstance(locations, list):
|
||||
locations = {}
|
||||
if isinstance(collectibles, list):
|
||||
collectibles = {}
|
||||
|
||||
if ctx.location_table != locations or ctx.collectible_table != collectibles:
|
||||
ctx.location_table = locations
|
||||
ctx.collectible_table = collectibles
|
||||
locs1 = [oot_loc_name_to_id[loc] for loc, b in ctx.location_table.items() if b]
|
||||
locs2 = [int(loc) for loc, b in ctx.collectible_table.items() if b]
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationChecks",
|
||||
"locations": locs1 + locs2
|
||||
}])
|
||||
|
||||
# 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 = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||
data_decoded = json.loads(data.decode())
|
||||
reported_version = data_decoded.get('scriptVersion', 0)
|
||||
if reported_version >= script_version:
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
async_start(parse_payload(data_decoded, ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = data_decoded['playerName']
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
else:
|
||||
if not ctx.version_warning:
|
||||
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}. "
|
||||
"Please update to the latest version. "
|
||||
"Your connection to the Archipelago server will not be accepted.")
|
||||
ctx.version_warning = True
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
if ctx.n64_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to N64")
|
||||
ctx.n64_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.n64_status = f"Was tentatively connected but error occured: {error_status}"
|
||||
elif error_status:
|
||||
ctx.n64_status = error_status
|
||||
logger.info("Lost connection to N64 and attempting to reconnect. Use /n64 for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to N64")
|
||||
ctx.n64_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28921), timeout=10)
|
||||
ctx.n64_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.n64_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif os.path.isfile(auto_start):
|
||||
subprocess.Popen([auto_start, romfile],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def patch_and_run_game(apz5_file):
|
||||
apz5_file = os.path.abspath(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_file_name = Utils.get_options()["oot_options"]["rom_file"]
|
||||
if not os.path.exists(rom_file_name):
|
||||
rom_file_name = Utils.user_path(rom_file_name)
|
||||
rom = Rom(rom_file_name)
|
||||
|
||||
sub_file = None
|
||||
if zipfile.is_zipfile(apz5_file):
|
||||
for name in zipfile.ZipFile(apz5_file).namelist():
|
||||
if name.endswith('.zpf'):
|
||||
sub_file = name
|
||||
break
|
||||
|
||||
apply_patch_file(rom, apz5_file, sub_file=sub_file)
|
||||
rom.write_to_file(decomp_path)
|
||||
os.chdir(data_path("Compress"))
|
||||
compress_rom_file(decomp_path, comp_path)
|
||||
os.remove(decomp_path)
|
||||
async_start(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...")
|
||||
async_start(patch_and_run_game(args.apz5_file))
|
||||
|
||||
ctx = OoTContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
ctx.n64_sync_task = asyncio.create_task(n64_sync_task(ctx), name="N64 Sync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.n64_sync_task:
|
||||
await ctx.n64_sync_task
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
1240
Options.py
1240
Options.py
File diff suppressed because it is too large
Load Diff
196
Patch.py
196
Patch.py
@@ -1,183 +1,35 @@
|
||||
import bsdiff4
|
||||
import yaml
|
||||
from __future__ import annotations
|
||||
|
||||
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, TypedDict
|
||||
|
||||
import Utils
|
||||
from worlds.alttp.Rom import JAP10HASH
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
current_patch_version = 1
|
||||
from worlds.Files import AutoPatchRegister, APDeltaPatch
|
||||
|
||||
|
||||
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 RomMeta(TypedDict):
|
||||
server: str
|
||||
player: Optional[int]
|
||||
player_name: str
|
||||
|
||||
|
||||
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")))
|
||||
|
||||
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
|
||||
|
||||
|
||||
def generate_yaml(patch: bytes, metadata: Optional[dict] = None) -> bytes:
|
||||
patch = yaml.dump({"meta": metadata,
|
||||
"patch": patch,
|
||||
"game": "alttp",
|
||||
"compatible_version": 1,
|
||||
# minimum version of patch system expected for patching to be successful
|
||||
"version": current_patch_version,
|
||||
"base_checksum": JAP10HASH})
|
||||
return patch.encode(encoding="utf-8-sig")
|
||||
|
||||
|
||||
def generate_patch(rom: bytes, metadata: Optional[dict] = None) -> bytes:
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
patch = bsdiff4.diff(get_base_rom_bytes(), rom)
|
||||
return generate_yaml(patch, metadata)
|
||||
|
||||
|
||||
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None) -> str:
|
||||
bytes = generate_patch(load_bytes(rom_file_to_patch),
|
||||
{
|
||||
"server": server}) # allow immediate connection to server in multiworld. Empty string otherwise
|
||||
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + ".apbp"
|
||||
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"))
|
||||
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"])
|
||||
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 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
|
||||
|
||||
|
||||
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"])
|
||||
return lzma.compress(bytes)
|
||||
|
||||
|
||||
def load_bytes(path: str) -> bytes:
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def write_lzma(data: bytes, path: str):
|
||||
with lzma.LZMAFile(path, 'wb') as f:
|
||||
f.write(data)
|
||||
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
|
||||
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
|
||||
raise NotImplementedError(f"No Handler for {patch_file} found.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = Utils.get_public_ipv4()
|
||||
options = Utils.get_options()['server_options']
|
||||
if options['host']:
|
||||
host = options['host']
|
||||
|
||||
address = f"{host}:{options['port']}"
|
||||
ziplock = threading.Lock()
|
||||
print(f"Host for patches to be created is {address}")
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
for rom in sys.argv:
|
||||
try:
|
||||
if rom.endswith(".sfc"):
|
||||
print(f"Creating patch for {rom}")
|
||||
result = pool.submit(create_patch_file, rom, address)
|
||||
result.add_done_callback(lambda task: print(f"Created patch {task.result()}"))
|
||||
|
||||
elif rom.endswith(".apbp"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
romfile, adjusted = Utils.get_adjuster_settings(target)
|
||||
if adjusted:
|
||||
try:
|
||||
os.replace(romfile, target)
|
||||
romfile = target
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print(f"Created rom {romfile if adjusted else target}.")
|
||||
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(".zip"):
|
||||
print(f"Updating host in patch files contained in {rom}")
|
||||
|
||||
|
||||
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
||||
data = zfr.read(zfinfo)
|
||||
if zfinfo.filename.endswith(".apbp"):
|
||||
data = update_patch_data(data, server)
|
||||
with ziplock:
|
||||
zfw.writestr(zfinfo, data)
|
||||
return zfinfo.filename
|
||||
|
||||
|
||||
futures = []
|
||||
with zipfile.ZipFile(rom, "r") as zfr:
|
||||
updated_zip = os.path.splitext(rom)[0] + "_updated.zip"
|
||||
with zipfile.ZipFile(updated_zip, "w", compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9) as zfw:
|
||||
for zfname in zfr.namelist():
|
||||
futures.append(pool.submit(_handle_zip_file_entry, zfr.getinfo(zfname), address))
|
||||
for future in futures:
|
||||
print(f"File {future.result()} added to {os.path.split(updated_zip)[1]}")
|
||||
|
||||
except:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
input("Press enter to close.")
|
||||
for file in sys.argv[1:]:
|
||||
meta_data, result_file = create_rom_file(file)
|
||||
print(f"Patch with meta-data {meta_data} was written to {result_file}")
|
||||
|
||||
351
PokemonClient.py
Normal file
351
PokemonClient.py
Normal file
@@ -0,0 +1,351 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import bsdiff4
|
||||
import subprocess
|
||||
import zipfile
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
from typing import List
|
||||
|
||||
|
||||
import Utils
|
||||
from Utils import async_start
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||
get_base_parser
|
||||
|
||||
from worlds.pokemon_rb.locations import location_data
|
||||
from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch
|
||||
|
||||
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}}
|
||||
location_bytes_bits = {}
|
||||
for location in location_data:
|
||||
if location.ram_address is not None:
|
||||
if type(location.ram_address) == list:
|
||||
location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address
|
||||
location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit},
|
||||
{'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}]
|
||||
else:
|
||||
location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address
|
||||
location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit}
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure pkmn_rb.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart pkmn_rb.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
DISPLAY_MSGS = True
|
||||
|
||||
SCRIPT_VERSION = 3
|
||||
|
||||
|
||||
class GBCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_gb(self):
|
||||
"""Check Gameboy Connection State"""
|
||||
if isinstance(self.ctx, GBContext):
|
||||
logger.info(f"Gameboy Status: {self.ctx.gb_status}")
|
||||
|
||||
|
||||
class GBContext(CommonContext):
|
||||
command_processor = GBCommandProcessor
|
||||
game = 'Pokemon Red and Blue'
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.gb_streams: (StreamReader, StreamWriter) = None
|
||||
self.gb_sync_task = None
|
||||
self.messages = {}
|
||||
self.locations_array = None
|
||||
self.gb_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.display_msgs = True
|
||||
self.deathlink_pending = False
|
||||
self.set_deathlink = False
|
||||
self.client_compatibility_mode = 0
|
||||
self.items_handling = 0b001
|
||||
self.sent_release = False
|
||||
self.sent_collect = False
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(GBContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to EmuHawk 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.locations_array = None
|
||||
if 'death_link' in args['slot_data'] and args['slot_data']['death_link']:
|
||||
self.set_deathlink = True
|
||||
elif cmd == "RoomInfo":
|
||||
self.seed_name = args['seed_name']
|
||||
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_names[item.item] for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
self.deathlink_pending = True
|
||||
super().on_deathlink(data)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class GBManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Pokémon Client"
|
||||
|
||||
self.ui = GBManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
def get_payload(ctx: GBContext):
|
||||
current_time = time.time()
|
||||
ret = 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},
|
||||
"deathlink": ctx.deathlink_pending,
|
||||
"options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled'))
|
||||
}
|
||||
)
|
||||
ctx.deathlink_pending = False
|
||||
return ret
|
||||
|
||||
|
||||
async def parse_locations(data: List, ctx: GBContext):
|
||||
locations = []
|
||||
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
|
||||
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E],
|
||||
"Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]}
|
||||
|
||||
if len(data) > 0x140 + 0x20 + 0x0E + 0x01:
|
||||
flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:]
|
||||
else:
|
||||
flags["DexSanityFlag"] = [0] * 19
|
||||
|
||||
for flag_type, loc_map in location_map.items():
|
||||
for flag, loc_id in loc_map.items():
|
||||
if flag_type == "list":
|
||||
if (flags["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << location_bytes_bits[loc_id][0]['bit']
|
||||
and flags["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << location_bytes_bits[loc_id][1]['bit']):
|
||||
locations.append(loc_id)
|
||||
elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
|
||||
locations.append(loc_id)
|
||||
if flags["EventFlag"][280] & 1 and not ctx.finished_game:
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "StatusUpdate",
|
||||
"status": 30}
|
||||
])
|
||||
ctx.finished_game = True
|
||||
if locations == ctx.locations_array:
|
||||
return
|
||||
ctx.locations_array = locations
|
||||
if locations is not None:
|
||||
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
|
||||
|
||||
|
||||
async def gb_sync_task(ctx: GBContext):
|
||||
logger.info("Starting GB connector. Use /gb for status information")
|
||||
while not ctx.exit_event.is_set():
|
||||
error_status = None
|
||||
if ctx.gb_streams:
|
||||
(reader, writer) = ctx.gb_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())
|
||||
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
|
||||
msg = "You are connecting with an incompatible Lua script version. Ensure your connector Lua " \
|
||||
"and PokemonClient are from the same Archipelago installation."
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
ctx.client_compatibility_mode = data_decoded['clientCompatibilityVersion']
|
||||
if ctx.client_compatibility_mode == 0:
|
||||
ctx.items_handling = 0b101 # old patches will not have local start inventory, must be requested
|
||||
if ctx.seed_name and ctx.seed_name != ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]):
|
||||
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
ctx.seed_name = ''.join([chr(i) for i in data_decoded['seedName'] if i != 0])
|
||||
if not ctx.auth:
|
||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||
if ctx.auth == '':
|
||||
msg = "Invalid ROM detected. No player name built into the ROM."
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
if 'locations' in data_decoded and ctx.game and ctx.gb_status == CONNECTION_CONNECTED_STATUS \
|
||||
and not error_status and ctx.auth:
|
||||
# Not just a keep alive ping, parse
|
||||
async_start(parse_locations(data_decoded['locations'], ctx))
|
||||
if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags:
|
||||
await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!")
|
||||
if 'options' in data_decoded:
|
||||
msgs = []
|
||||
if data_decoded['options'] & 4 and not ctx.sent_release:
|
||||
ctx.sent_release = True
|
||||
msgs.append({"cmd": "Say", "text": "!release"})
|
||||
if data_decoded['options'] & 8 and not ctx.sent_collect:
|
||||
ctx.sent_collect = True
|
||||
msgs.append({"cmd": "Say", "text": "!collect"})
|
||||
if msgs:
|
||||
await ctx.send_msgs(msgs)
|
||||
if ctx.set_deathlink:
|
||||
await ctx.update_death_link(True)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.gb_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.gb_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.gb_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.gb_streams = None
|
||||
if ctx.gb_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to Gameboy")
|
||||
ctx.gb_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.gb_status = f"Was tentatively connected but error occured: {error_status}"
|
||||
elif error_status:
|
||||
ctx.gb_status = error_status
|
||||
logger.info("Lost connection to Gameboy and attempting to reconnect. Use /gb for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to Gameboy")
|
||||
ctx.gb_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 17242), timeout=10)
|
||||
ctx.gb_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.gb_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.gb_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = Utils.get_options()["pokemon_rb_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(game_version, patch_file, ctx):
|
||||
base_name = os.path.splitext(patch_file)[0]
|
||||
comp_path = base_name + '.gb'
|
||||
if game_version == "blue":
|
||||
delta_patch = BlueDeltaPatch
|
||||
else:
|
||||
delta_patch = RedDeltaPatch
|
||||
|
||||
try:
|
||||
base_rom = delta_patch.get_source_data()
|
||||
except Exception as msg:
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
|
||||
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
|
||||
with patch_archive.open('delta.bsdiff4', 'r') as stream:
|
||||
patch = stream.read()
|
||||
patched_rom_data = bsdiff4.patch(base_rom, patch)
|
||||
|
||||
with open(comp_path, "wb") as patched_rom_file:
|
||||
patched_rom_file.write(patched_rom_data)
|
||||
|
||||
async_start(run_game(comp_path))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Utils.init_logging("PokemonClient")
|
||||
|
||||
options = Utils.get_options()
|
||||
|
||||
async def main():
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('patch_file', default="", type=str, nargs="?",
|
||||
help='Path to an APRED or APBLUE patch file')
|
||||
args = parser.parse_args()
|
||||
|
||||
ctx = GBContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
ctx.gb_sync_task = asyncio.create_task(gb_sync_task(ctx), name="GB Sync")
|
||||
|
||||
if args.patch_file:
|
||||
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
|
||||
if ext == "apred":
|
||||
logger.info("APRED file supplied, beginning patching process...")
|
||||
async_start(patch_and_run_game("red", args.patch_file, ctx))
|
||||
elif ext == "apblue":
|
||||
logger.info("APBLUE file supplied, beginning patching process...")
|
||||
async_start(patch_and_run_game("blue", args.patch_file, ctx))
|
||||
else:
|
||||
logger.warning(f"Unknown patch file extension {ext}")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.gb_sync_task:
|
||||
await ctx.gb_sync_task
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
69
README.md
69
README.md
@@ -6,8 +6,51 @@ 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
|
||||
* The Witness
|
||||
* Sonic Adventure 2: Battle
|
||||
* Starcraft 2: Wings of Liberty
|
||||
* Donkey Kong Country 3
|
||||
* Dark Souls 3
|
||||
* Super Mario World
|
||||
* Pokémon Red and Blue
|
||||
* Hylics 2
|
||||
* Overcooked! 2
|
||||
* Zillion
|
||||
* Lufia II Ancient Cave
|
||||
* Blasphemous
|
||||
* Wargroove
|
||||
* Stardew Valley
|
||||
* The Legend of Zelda
|
||||
* The Messenger
|
||||
* Kingdom Hearts 2
|
||||
* The Legend of Zelda: Link's Awakening DX
|
||||
* Clique
|
||||
* Adventure
|
||||
* DLC Quest
|
||||
* Noita
|
||||
* Undertale
|
||||
* Bumper Stickers
|
||||
* Mega Man Battle Network 3: Blue Version
|
||||
* Muse Dash
|
||||
|
||||
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,30 +72,20 @@ 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 doc on [running Archipelago from source](docs/running%20from%20source.md).
|
||||
|
||||
## Related Repositories
|
||||
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
|
||||
|
||||
* [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.
|
||||
For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md)
|
||||
|
||||
* Ensure that all changes which affect logic are covered by unit tests.
|
||||
* Do not introduce any unit test failures/regressions.
|
||||
|
||||
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.)
|
||||
## FAQ
|
||||
For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/)
|
||||
|
||||
## Code of Conduct
|
||||
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:
|
||||
|
||||
* Be welcoming and inclusive in tone and language.
|
||||
* Be respectful of others and their abilities.
|
||||
* Show empathy when speaking with others.
|
||||
* Be gracious and accept feedback and constructive criticism.
|
||||
|
||||
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private, such as private messaging or emails.
|
||||
|
||||
Any incidents of abuse may be reported directly to Ijwu at hmfarran@gmail.com.
|
||||
Please refer to our [code of conduct.](/docs/code_of_conduct.md)
|
||||
|
||||
727
SNIClient.py
Normal file
727
SNIClient.py
Normal file
@@ -0,0 +1,727 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import multiprocessing
|
||||
import os
|
||||
import subprocess
|
||||
import base64
|
||||
import logging
|
||||
import asyncio
|
||||
import enum
|
||||
import typing
|
||||
|
||||
from json import loads, dumps
|
||||
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
|
||||
import Utils
|
||||
from Utils import async_start
|
||||
from MultiServer import mark_raw
|
||||
if typing.TYPE_CHECKING:
|
||||
from worlds.AutoSNIClient import SNIClient
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("SNIClient", exception_logger="Client")
|
||||
|
||||
import colorama
|
||||
from websockets.client import connect as websockets_connect, WebSocketClientProtocol
|
||||
from websockets.exceptions import WebSocketException, ConnectionClosed
|
||||
|
||||
snes_logger = logging.getLogger("SNES")
|
||||
|
||||
|
||||
class DeathState(enum.IntEnum):
|
||||
killing_player = 1
|
||||
alive = 2
|
||||
dead = 3
|
||||
|
||||
|
||||
class SNIClientCommandProcessor(ClientCommandProcessor):
|
||||
ctx: SNIContext
|
||||
|
||||
def _cmd_slow_mode(self, toggle: str = "") -> None:
|
||||
"""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_options: str = "") -> bool:
|
||||
"""Connect to a snes. Optionally include network address of a snes to connect to,
|
||||
otherwise show available devices; and a SNES device number if more than one SNES is detected.
|
||||
Examples: "/snes", "/snes 1", "/snes localhost:23074 1" """
|
||||
if self.ctx.snes_state in {SNESState.SNES_ATTACHED, SNESState.SNES_CONNECTED, SNESState.SNES_CONNECTING}:
|
||||
self.output("Already connected to SNES. Disconnecting first.")
|
||||
self._cmd_snes_close()
|
||||
return self.connect_to_snes(snes_options)
|
||||
|
||||
def connect_to_snes(self, snes_options: str = "") -> bool:
|
||||
snes_address = self.ctx.snes_address
|
||||
snes_device_number = -1
|
||||
|
||||
options = snes_options.split()
|
||||
num_options = len(options)
|
||||
|
||||
if num_options > 0:
|
||||
snes_device_number = int(options[0])
|
||||
|
||||
if num_options > 1:
|
||||
snes_address = options[0]
|
||||
snes_device_number = int(options[1])
|
||||
|
||||
self.ctx.snes_reconnect_address = None
|
||||
if self.ctx.snes_connect_task:
|
||||
self.ctx.snes_connect_task.cancel()
|
||||
self.ctx.snes_connect_task = asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number),
|
||||
name="SNES Connect")
|
||||
return True
|
||||
|
||||
def _cmd_snes_close(self) -> bool:
|
||||
"""Close connection to a currently connected snes"""
|
||||
self.ctx.snes_reconnect_address = None
|
||||
self.ctx.cancel_snes_autoreconnect()
|
||||
if self.ctx.snes_socket and not self.ctx.snes_socket.closed:
|
||||
async_start(self.ctx.snes_socket.close())
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
# Left here for quick re-addition for debugging.
|
||||
# def _cmd_snes_write(self, address, data):
|
||||
# """Write the specified byte (base10) to the SNES' memory address (base16)."""
|
||||
# if self.ctx.snes_state != SNESState.SNES_ATTACHED:
|
||||
# self.output("No attached SNES Device.")
|
||||
# return False
|
||||
# snes_buffered_write(self.ctx, int(address, 16), bytes([int(data)]))
|
||||
# async_start(snes_flush_writes(self.ctx))
|
||||
# self.output("Data Sent")
|
||||
# return True
|
||||
|
||||
# def _cmd_snes_read(self, address, size=1):
|
||||
# """Read the SNES' memory address (base16)."""
|
||||
# if self.ctx.snes_state != SNESState.SNES_ATTACHED:
|
||||
# self.output("No attached SNES Device.")
|
||||
# return False
|
||||
# data = await snes_read(self.ctx, int(address, 16), size)
|
||||
# self.output(f"Data Read: {data}")
|
||||
# return True
|
||||
|
||||
|
||||
class SNIContext(CommonContext):
|
||||
command_processor: typing.Type[SNIClientCommandProcessor] = SNIClientCommandProcessor
|
||||
game: typing.Optional[str] = None # set in validate_rom
|
||||
items_handling: typing.Optional[int] = None # set in game_watcher
|
||||
snes_connect_task: "typing.Optional[asyncio.Task[None]]" = None
|
||||
snes_autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
|
||||
snes_address: str
|
||||
snes_socket: typing.Optional[WebSocketClientProtocol]
|
||||
snes_state: SNESState
|
||||
snes_attached_device: typing.Optional[typing.Tuple[int, str]]
|
||||
snes_reconnect_address: typing.Optional[str]
|
||||
snes_recv_queue: "asyncio.Queue[bytes]"
|
||||
snes_request_lock: asyncio.Lock
|
||||
snes_write_buffer: typing.List[typing.Tuple[int, bytes]]
|
||||
snes_connector_lock: threading.Lock
|
||||
death_state: DeathState
|
||||
killing_player_task: "typing.Optional[asyncio.Task[None]]"
|
||||
allow_collect: bool
|
||||
slow_mode: bool
|
||||
|
||||
client_handler: typing.Optional[SNIClient]
|
||||
awaiting_rom: bool
|
||||
rom: typing.Optional[bytes]
|
||||
prev_rom: typing.Optional[bytes]
|
||||
|
||||
hud_message_queue: typing.List[str] # TODO: str is a guess, is this right?
|
||||
death_link_allow_survive: bool
|
||||
|
||||
def __init__(self, snes_address: str, server_address: str, password: str) -> None:
|
||||
super(SNIContext, self).__init__(server_address, password)
|
||||
|
||||
# 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.snes_connector_lock = threading.Lock()
|
||||
self.death_state = DeathState.alive # for death link flop behaviour
|
||||
self.killing_player_task = None
|
||||
self.allow_collect = False
|
||||
self.slow_mode = False
|
||||
|
||||
self.client_handler = None
|
||||
self.awaiting_rom = False
|
||||
self.rom = None
|
||||
self.prev_rom = None
|
||||
|
||||
async def connection_closed(self) -> None:
|
||||
await super(SNIContext, self).connection_closed()
|
||||
self.awaiting_rom = False
|
||||
|
||||
def event_invalid_slot(self) -> typing.NoReturn:
|
||||
if self.snes_socket is not None and not self.snes_socket.closed:
|
||||
async_start(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: bool = False) -> None:
|
||||
if password_requested and not self.password:
|
||||
await super(SNIContext, self).server_auth(password_requested)
|
||||
if self.rom is None:
|
||||
self.awaiting_rom = True
|
||||
snes_logger.info(
|
||||
"No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)")
|
||||
return
|
||||
self.awaiting_rom = False
|
||||
# TODO: This looks kind of hacky...
|
||||
# Context.auth is meant to be the "name" parameter in send_connect,
|
||||
# which has to be a str (bytes is not json serializable).
|
||||
# But here, Context.auth is being used for something else
|
||||
# (where it has to be bytes because it is compared with rom elsewhere).
|
||||
# If we need to save something to compare with rom elsewhere,
|
||||
# it should probably be in a different variable,
|
||||
# and let auth be used for what it's meant for.
|
||||
self.auth = self.rom
|
||||
auth = base64.b64encode(self.rom).decode()
|
||||
await self.send_connect(name=auth)
|
||||
|
||||
def cancel_snes_autoreconnect(self) -> bool:
|
||||
if self.snes_autoreconnect_task:
|
||||
self.snes_autoreconnect_task.cancel()
|
||||
self.snes_autoreconnect_task = None
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||
if not self.killing_player_task or self.killing_player_task.done():
|
||||
self.killing_player_task = asyncio.create_task(deathlink_kill_player(self))
|
||||
super(SNIContext, self).on_deathlink(data)
|
||||
|
||||
async def handle_deathlink_state(self, currently_dead: bool) -> None:
|
||||
# in this state we only care about triggering a death send
|
||||
if self.death_state == DeathState.alive:
|
||||
if currently_dead:
|
||||
self.death_state = DeathState.dead
|
||||
await self.send_death()
|
||||
# in this state we care about confirming a kill, to move state to dead
|
||||
elif self.death_state == DeathState.killing_player:
|
||||
# this is being handled in deathlink_kill_player(ctx) already
|
||||
pass
|
||||
# in this state we wait until the player is alive again
|
||||
elif self.death_state == DeathState.dead:
|
||||
if not currently_dead:
|
||||
self.death_state = DeathState.alive
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
await super(SNIContext, self).shutdown()
|
||||
self.cancel_snes_autoreconnect()
|
||||
if self.snes_connect_task:
|
||||
try:
|
||||
await asyncio.wait_for(self.snes_connect_task, 1)
|
||||
except asyncio.TimeoutError:
|
||||
self.snes_connect_task.cancel()
|
||||
|
||||
def on_package(self, cmd: str, args: typing.Dict[str, typing.Any]) -> None:
|
||||
if cmd in {"Connected", "RoomUpdate"}:
|
||||
if "checked_locations" in args and args["checked_locations"]:
|
||||
new_locations = set(args["checked_locations"])
|
||||
self.checked_locations |= new_locations
|
||||
self.locations_scouted |= new_locations
|
||||
# Items belonging to the player should not be marked as checked in game,
|
||||
# since the player will likely need that item.
|
||||
# Once the games handled by SNIClient gets made to be remote items,
|
||||
# this will no longer be needed.
|
||||
async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
|
||||
|
||||
def run_gui(self) -> None:
|
||||
from kvui import GameManager
|
||||
|
||||
class SNIManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("SNES", "SNES"),
|
||||
]
|
||||
base_title = "Archipelago SNI Client"
|
||||
|
||||
self.ui = SNIManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") # type: ignore
|
||||
|
||||
|
||||
async def deathlink_kill_player(ctx: SNIContext) -> None:
|
||||
ctx.death_state = DeathState.killing_player
|
||||
while ctx.death_state == DeathState.killing_player and \
|
||||
ctx.snes_state == SNESState.SNES_ATTACHED:
|
||||
|
||||
if ctx.client_handler is None:
|
||||
continue
|
||||
|
||||
await ctx.client_handler.deathlink_kill_player(ctx)
|
||||
|
||||
ctx.last_death_link = time.time()
|
||||
|
||||
|
||||
_global_snes_reconnect_delay = 5
|
||||
|
||||
|
||||
class SNESState(enum.IntEnum):
|
||||
SNES_DISCONNECTED = 0
|
||||
SNES_CONNECTING = 1
|
||||
SNES_CONNECTED = 2
|
||||
SNES_ATTACHED = 3
|
||||
|
||||
|
||||
def launch_sni() -> None:
|
||||
sni_path = Utils.get_options()["sni_options"]["sni_path"]
|
||||
|
||||
if not os.path.isdir(sni_path):
|
||||
sni_path = Utils.local_path(sni_path)
|
||||
if os.path.isdir(sni_path):
|
||||
dir_entry: "os.DirEntry[str]"
|
||||
for dir_entry in os.scandir(sni_path):
|
||||
if dir_entry.is_file():
|
||||
lower_file = dir_entry.name.lower()
|
||||
if (lower_file.startswith("sni.") and not lower_file.endswith(".proto")) or (lower_file == "sni"):
|
||||
sni_path = dir_entry.path
|
||||
break
|
||||
|
||||
if os.path.isfile(sni_path):
|
||||
snes_logger.info(f"Attempting to start {sni_path}")
|
||||
import sys
|
||||
if not sys.stdout: # if it spawns a visible console, may as well populate it
|
||||
subprocess.Popen(os.path.abspath(sni_path), cwd=os.path.dirname(sni_path))
|
||||
else:
|
||||
proc = subprocess.Popen(os.path.abspath(sni_path), cwd=os.path.dirname(sni_path),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
proc.wait(.1) # wait a bit to see if startup fails (missing dependencies)
|
||||
snes_logger.info('Failed to start SNI. Try running it externally for error output.')
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # seems to be running
|
||||
|
||||
else:
|
||||
snes_logger.info(
|
||||
f"Attempt to start SNI was aborted as path {sni_path} was not found, "
|
||||
f"please start it yourself if it is not running")
|
||||
|
||||
|
||||
async def _snes_connect(ctx: SNIContext, address: str, retry: bool = True) -> WebSocketClientProtocol:
|
||||
address = f"ws://{address}" if "://" not in address else address
|
||||
snes_logger.info("Connecting to SNI at %s ..." % address)
|
||||
seen_problems: typing.Set[str] = set()
|
||||
while True:
|
||||
try:
|
||||
snes_socket = await websockets_connect(address, ping_timeout=None, ping_interval=None)
|
||||
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)
|
||||
snes_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()
|
||||
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
return snes_socket
|
||||
if not retry:
|
||||
break
|
||||
|
||||
|
||||
class SNESRequest(typing.TypedDict):
|
||||
Opcode: str
|
||||
Space: str
|
||||
Operands: typing.List[str]
|
||||
# TODO: When Python 3.11 is the lowest version supported, `Operands` can use `typing.NotRequired` (pep-0655)
|
||||
# Then the `Operands` key doesn't need to be given for opcodes that don't use it.
|
||||
|
||||
|
||||
async def get_snes_devices(ctx: SNIContext) -> typing.List[str]:
|
||||
socket = await _snes_connect(ctx, ctx.snes_address) # establish new connection to poll
|
||||
DeviceList_Request: SNESRequest = {
|
||||
"Opcode": "DeviceList",
|
||||
"Space": "SNES",
|
||||
"Operands": []
|
||||
}
|
||||
await socket.send(dumps(DeviceList_Request))
|
||||
|
||||
reply: typing.Dict[str, typing.Any] = loads(await socket.recv())
|
||||
devices: typing.List[str] = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else []
|
||||
|
||||
if not devices:
|
||||
snes_logger.info('No SNES device found. Please connect a SNES device to SNI.')
|
||||
while not devices and not ctx.exit_event.is_set():
|
||||
await asyncio.sleep(0.1)
|
||||
await socket.send(dumps(DeviceList_Request))
|
||||
reply = loads(await socket.recv())
|
||||
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else []
|
||||
if devices:
|
||||
await verify_snes_app(socket)
|
||||
await socket.close()
|
||||
return sorted(devices)
|
||||
|
||||
|
||||
async def verify_snes_app(socket: WebSocketClientProtocol) -> None:
|
||||
AppVersion_Request = {
|
||||
"Opcode": "AppVersion",
|
||||
}
|
||||
await socket.send(dumps(AppVersion_Request))
|
||||
|
||||
app: str = loads(await socket.recv())["Results"][0]
|
||||
if "SNI" not in app:
|
||||
snes_logger.warning(f"Warning: Did not find SNI as the endpoint, instead {app} was found.")
|
||||
|
||||
|
||||
async def snes_connect(ctx: SNIContext, address: str, deviceIndex: int = -1) -> None:
|
||||
global _global_snes_reconnect_delay
|
||||
if ctx.snes_socket is not None and ctx.snes_state == SNESState.SNES_CONNECTED:
|
||||
if ctx.rom:
|
||||
snes_logger.error('Already connected to SNES, with rom loaded.')
|
||||
else:
|
||||
snes_logger.error('Already connected to SNI, likely awaiting a device.')
|
||||
return
|
||||
|
||||
ctx.cancel_snes_autoreconnect()
|
||||
|
||||
device = None
|
||||
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)
|
||||
device_count = len(devices)
|
||||
|
||||
if device_count == 1:
|
||||
device = devices[0]
|
||||
elif ctx.snes_reconnect_address:
|
||||
assert ctx.snes_attached_device
|
||||
if ctx.snes_attached_device[1] in devices:
|
||||
device = ctx.snes_attached_device[1]
|
||||
else:
|
||||
device = devices[ctx.snes_attached_device[0]]
|
||||
elif device_count > 1:
|
||||
if deviceIndex == -1:
|
||||
snes_logger.info(f"Found {device_count} SNES devices. "
|
||||
f"Connect to one with /snes <address> <device number>. For example /snes {address} 1")
|
||||
|
||||
for idx, availableDevice in enumerate(devices):
|
||||
snes_logger.info(str(idx + 1) + ": " + availableDevice)
|
||||
|
||||
elif (deviceIndex < 0) or (deviceIndex - 1) > device_count:
|
||||
snes_logger.warning("SNES device number out of range")
|
||||
|
||||
else:
|
||||
device = devices[deviceIndex - 1]
|
||||
|
||||
if device is None:
|
||||
await snes_disconnect(ctx)
|
||||
return
|
||||
|
||||
snes_logger.info("Attaching to " + device)
|
||||
|
||||
Attach_Request: SNESRequest = {
|
||||
"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))
|
||||
|
||||
except Exception as e:
|
||||
ctx.snes_state = SNESState.SNES_DISCONNECTED
|
||||
if task_alive(recv_task):
|
||||
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
|
||||
snes_logger.error(f"Error connecting to snes ({e}), retrying in {_global_snes_reconnect_delay} seconds")
|
||||
ctx.snes_autoreconnect_task = asyncio.create_task(snes_autoreconnect(ctx), name="snes auto-reconnect")
|
||||
_global_snes_reconnect_delay *= 2
|
||||
else:
|
||||
_global_snes_reconnect_delay = ctx.starting_reconnect_delay
|
||||
snes_logger.info(f"Attached to {device}")
|
||||
|
||||
|
||||
async def snes_disconnect(ctx: SNIContext) -> None:
|
||||
if ctx.snes_socket:
|
||||
if not ctx.snes_socket.closed:
|
||||
await ctx.snes_socket.close()
|
||||
ctx.snes_socket = None
|
||||
|
||||
|
||||
def task_alive(task: typing.Optional[asyncio.Task]) -> bool:
|
||||
if task:
|
||||
return not task.done()
|
||||
return False
|
||||
|
||||
|
||||
async def snes_autoreconnect(ctx: SNIContext) -> None:
|
||||
await asyncio.sleep(_global_snes_reconnect_delay)
|
||||
if not ctx.snes_socket and not task_alive(ctx.snes_connect_task):
|
||||
address = ctx.snes_reconnect_address if ctx.snes_reconnect_address else ctx.snes_address
|
||||
ctx.snes_connect_task = asyncio.create_task(snes_connect(ctx, address), name="SNES Connect")
|
||||
|
||||
|
||||
async def snes_recv_loop(ctx: SNIContext) -> None:
|
||||
try:
|
||||
if ctx.snes_socket is None:
|
||||
raise Exception("invalid context state - snes_socket not connected")
|
||||
async for msg in ctx.snes_socket:
|
||||
ctx.snes_recv_queue.put_nowait(typing.cast(bytes, msg))
|
||||
snes_logger.warning("Snes disconnected")
|
||||
except Exception as e:
|
||||
if not isinstance(e, WebSocketException):
|
||||
snes_logger.exception(e)
|
||||
snes_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:
|
||||
snes_logger.info(f"... automatically reconnecting to snes in {_global_snes_reconnect_delay} seconds")
|
||||
assert ctx.snes_autoreconnect_task is None
|
||||
ctx.snes_autoreconnect_task = asyncio.create_task(snes_autoreconnect(ctx), name="snes auto-reconnect")
|
||||
|
||||
|
||||
async def snes_read(ctx: SNIContext, address: int, size: int) -> typing.Optional[bytes]:
|
||||
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: SNESRequest = {
|
||||
"Opcode": "GetAddress",
|
||||
"Space": "SNES",
|
||||
"Operands": [hex(address)[2:], hex(size)[2:]]
|
||||
}
|
||||
try:
|
||||
await ctx.snes_socket.send(dumps(GetAddress_Request))
|
||||
except ConnectionClosed:
|
||||
return None
|
||||
|
||||
data: bytes = 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:
|
||||
snes_logger.error('Error reading %s, requested %d bytes, received %d' % (hex(address), size, len(data)))
|
||||
if len(data):
|
||||
snes_logger.error(str(data))
|
||||
snes_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: SNIContext, write_list: typing.List[typing.Tuple[int, bytes]]) -> bool:
|
||||
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: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
|
||||
try:
|
||||
for address, data in write_list:
|
||||
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
|
||||
# REVIEW: above: `if snes_socket is None: return False`
|
||||
# Does it need to be checked again?
|
||||
if ctx.snes_socket is not None:
|
||||
await ctx.snes_socket.send(dumps(PutAddress_Request))
|
||||
await ctx.snes_socket.send(data)
|
||||
else:
|
||||
snes_logger.warning(f"Could not send data to SNES: {data}")
|
||||
except ConnectionClosed:
|
||||
return False
|
||||
|
||||
return True
|
||||
finally:
|
||||
ctx.snes_request_lock.release()
|
||||
|
||||
|
||||
def snes_buffered_write(ctx: SNIContext, address: int, data: bytes) -> None:
|
||||
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: SNIContext) -> None:
|
||||
if not ctx.snes_write_buffer:
|
||||
return
|
||||
|
||||
# swap buffers
|
||||
ctx.snes_write_buffer, writes = [], ctx.snes_write_buffer
|
||||
await snes_write(ctx, writes)
|
||||
|
||||
|
||||
async def game_watcher(ctx: SNIContext) -> None:
|
||||
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 or not ctx.client_handler:
|
||||
ctx.finished_game = False
|
||||
ctx.death_link_allow_survive = False
|
||||
|
||||
from worlds.AutoSNIClient import AutoSNIClientRegister
|
||||
ctx.client_handler = await AutoSNIClientRegister.get_handler(ctx)
|
||||
|
||||
if not ctx.client_handler:
|
||||
continue
|
||||
|
||||
if not ctx.rom:
|
||||
continue
|
||||
|
||||
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
|
||||
ctx.locations_checked = set()
|
||||
ctx.locations_scouted = set()
|
||||
ctx.locations_info = {}
|
||||
ctx.prev_rom = ctx.rom
|
||||
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
elif ctx.server is None:
|
||||
snes_logger.warning("ROM detected but no active multiworld server connection. " +
|
||||
"Connect using command: /connect server:port")
|
||||
|
||||
if not ctx.client_handler:
|
||||
continue
|
||||
|
||||
rom_validated = await ctx.client_handler.validate_rom(ctx)
|
||||
|
||||
if not rom_validated or (ctx.auth and ctx.auth != ctx.rom):
|
||||
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
|
||||
await ctx.disconnect(allow_autoreconnect=True)
|
||||
ctx.client_handler = None
|
||||
ctx.rom = None
|
||||
ctx.command_processor(ctx).connect_to_snes()
|
||||
continue
|
||||
|
||||
delay = 7 if ctx.slow_mode else 0
|
||||
if time.perf_counter() - perf_counter < delay:
|
||||
continue
|
||||
|
||||
perf_counter = time.perf_counter()
|
||||
|
||||
await ctx.client_handler.game_watcher(ctx)
|
||||
|
||||
|
||||
async def run_game(romfile: str) -> None:
|
||||
auto_start = typing.cast(typing.Union[bool, str],
|
||||
Utils.get_options()["sni_options"].get("snes_rom_start", True))
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif isinstance(auto_start, str) and os.path.isfile(auto_start):
|
||||
subprocess.Popen([auto_start, romfile],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
multiprocessing.freeze_support()
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||
help='Path to a Archipelago Binary Patch file')
|
||||
parser.add_argument('--snes', default='localhost:23074', help='Address of the SNI server.')
|
||||
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.diff_file:
|
||||
import Patch
|
||||
logging.info("Patch file was supplied. Creating sfc rom..")
|
||||
try:
|
||||
meta, romfile = Patch.create_rom_file(args.diff_file)
|
||||
except Exception as e:
|
||||
Utils.messagebox('Error', str(e), True)
|
||||
raise
|
||||
args.connect = meta["server"]
|
||||
logging.info(f"Wrote rom file to {romfile}")
|
||||
if args.diff_file.endswith(".apsoe"):
|
||||
import webbrowser
|
||||
async_start(run_game(romfile))
|
||||
await _snes_connect(SNIContext(args.snes, args.connect, args.password), args.snes, False)
|
||||
webbrowser.open(f"http://www.evermizer.com/apclient/#server={meta['server']}")
|
||||
logging.info("Starting Evermizer Client in your Browser...")
|
||||
import time
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
elif args.diff_file.endswith(".aplttp"):
|
||||
from worlds.alttp.Client import get_alttp_settings
|
||||
adjustedromfile, adjusted = get_alttp_settings(romfile)
|
||||
async_start(run_game(adjustedromfile if adjusted else romfile))
|
||||
else:
|
||||
async_start(run_game(romfile))
|
||||
|
||||
ctx = SNIContext(args.snes, args.connect, args.password)
|
||||
if ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
ctx.snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address), name="SNES Connect")
|
||||
watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
|
||||
ctx.server_address = None
|
||||
ctx.snes_reconnect_address = None
|
||||
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
|
||||
await ctx.snes_socket.close()
|
||||
await watcher_task
|
||||
await ctx.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
1049
Starcraft2Client.py
Normal file
1049
Starcraft2Client.py
Normal file
File diff suppressed because it is too large
Load Diff
498
UndertaleClient.py
Normal file
498
UndertaleClient.py
Normal file
@@ -0,0 +1,498 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import asyncio
|
||||
import typing
|
||||
import bsdiff4
|
||||
import shutil
|
||||
|
||||
import Utils
|
||||
|
||||
from NetUtils import NetworkItem, ClientStatus
|
||||
from worlds import undertale
|
||||
from MultiServer import mark_raw
|
||||
from CommonClient import CommonContext, server_loop, \
|
||||
gui_enabled, ClientCommandProcessor, get_base_parser
|
||||
from Utils import async_start
|
||||
|
||||
|
||||
class UndertaleCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
self.output(f"Syncing items.")
|
||||
self.ctx.syncing = True
|
||||
|
||||
def _cmd_patch(self):
|
||||
"""Patch the game."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
|
||||
self.ctx.patch_game()
|
||||
self.output("Patched.")
|
||||
|
||||
@mark_raw
|
||||
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
|
||||
"""Patch the game automatically."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
|
||||
tempInstall = steaminstall
|
||||
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
||||
tempInstall = None
|
||||
if tempInstall is None:
|
||||
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
|
||||
if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
|
||||
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
|
||||
elif not os.path.exists(tempInstall):
|
||||
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
|
||||
if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
|
||||
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
|
||||
if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
||||
self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder."
|
||||
" command. \"/auto_patch (Steam directory)\".")
|
||||
else:
|
||||
for file_name in os.listdir(tempInstall):
|
||||
if file_name != "steam_api.dll":
|
||||
shutil.copy(tempInstall+"\\"+file_name,
|
||||
os.getcwd() + "\\Undertale\\" + file_name)
|
||||
self.ctx.patch_game()
|
||||
self.output("Patching successful!")
|
||||
|
||||
def _cmd_online(self):
|
||||
"""Makes you no longer able to see other Undertale players."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
self.ctx.update_online_mode(not ("Online" in self.ctx.tags))
|
||||
if "Online" in self.ctx.tags:
|
||||
self.output(f"Now online.")
|
||||
else:
|
||||
self.output(f"Now offline.")
|
||||
|
||||
def _cmd_deathlink(self):
|
||||
"""Toggles deathlink"""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
self.ctx.deathlink_status = not self.ctx.deathlink_status
|
||||
if self.ctx.deathlink_status:
|
||||
self.output(f"Deathlink enabled.")
|
||||
else:
|
||||
self.output(f"Deathlink disabled.")
|
||||
|
||||
|
||||
class UndertaleContext(CommonContext):
|
||||
tags = {"AP", "Online"}
|
||||
game = "Undertale"
|
||||
command_processor = UndertaleCommandProcessor
|
||||
items_handling = 0b111
|
||||
route = None
|
||||
pieces_needed = None
|
||||
completed_routes = None
|
||||
completed_count = 0
|
||||
save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.pieces_needed = 0
|
||||
self.game = "Undertale"
|
||||
self.got_deathlink = False
|
||||
self.syncing = False
|
||||
self.deathlink_status = False
|
||||
self.tem_armor = False
|
||||
self.completed_count = 0
|
||||
self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0}
|
||||
|
||||
def patch_game(self):
|
||||
with open(os.getcwd() + "/Undertale/data.win", "rb") as f:
|
||||
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
|
||||
with open(os.getcwd() + "/Undertale/data.win", "wb") as f:
|
||||
f.write(patchedFile)
|
||||
os.makedirs(name=os.getcwd() + "\\Undertale\\" + "Custom Sprites", exist_ok=True)
|
||||
with open(os.path.expandvars(os.getcwd() + "\\Undertale\\" + "Custom Sprites\\" +
|
||||
"Which Character.txt"), "w") as f:
|
||||
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
|
||||
"line other than this one.\n", "frisk"])
|
||||
f.close()
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super().server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
def clear_undertale_files(self):
|
||||
path = self.save_game_folder
|
||||
self.finished_game = False
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if "check.spot" == file or "scout" == file:
|
||||
os.remove(os.path.join(root, file))
|
||||
elif file.endswith((".item", ".victory", ".route", ".playerspot", ".mad",
|
||||
".youDied", ".LV", ".mine", ".flag", ".hint")):
|
||||
os.remove(os.path.join(root, file))
|
||||
|
||||
async def connect(self, address: typing.Optional[str] = None):
|
||||
self.clear_undertale_files()
|
||||
await super().connect(address)
|
||||
|
||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||
self.clear_undertale_files()
|
||||
await super().disconnect(allow_autoreconnect)
|
||||
|
||||
async def connection_closed(self):
|
||||
self.clear_undertale_files()
|
||||
await super().connection_closed()
|
||||
|
||||
async def shutdown(self):
|
||||
self.clear_undertale_files()
|
||||
await super().shutdown()
|
||||
|
||||
def update_online_mode(self, online):
|
||||
old_tags = self.tags.copy()
|
||||
if online:
|
||||
self.tags.add("Online")
|
||||
else:
|
||||
self.tags -= {"Online"}
|
||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||
async_start(self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]))
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.slot_info[self.slot].game
|
||||
async_start(process_undertale_cmd(self, cmd, args))
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class UTManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Undertale Client"
|
||||
|
||||
self.ui = UTManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_deathlink(self, data: typing.Dict[str, typing.Any]):
|
||||
self.got_deathlink = True
|
||||
super().on_deathlink(data)
|
||||
|
||||
|
||||
def to_room_name(place_name: str):
|
||||
if place_name == "Old Home Exit":
|
||||
return "room_ruinsexit"
|
||||
elif place_name == "Snowdin Forest":
|
||||
return "room_tundra1"
|
||||
elif place_name == "Snowdin Town Exit":
|
||||
return "room_fogroom"
|
||||
elif place_name == "Waterfall":
|
||||
return "room_water1"
|
||||
elif place_name == "Waterfall Exit":
|
||||
return "room_fire2"
|
||||
elif place_name == "Hotland":
|
||||
return "room_fire_prelab"
|
||||
elif place_name == "Hotland Exit":
|
||||
return "room_fire_precore"
|
||||
elif place_name == "Core":
|
||||
return "room_fire_core1"
|
||||
|
||||
|
||||
async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
if not os.path.exists(ctx.save_game_folder):
|
||||
os.mkdir(ctx.save_game_folder)
|
||||
ctx.route = args["slot_data"]["route"]
|
||||
ctx.pieces_needed = args["slot_data"]["key_pieces"]
|
||||
ctx.tem_armor = args["slot_data"]["temy_armor_include"]
|
||||
|
||||
await ctx.send_msgs([{"cmd": "Get", "keys": [str(ctx.slot)+" RoutesDone neutral",
|
||||
str(ctx.slot)+" RoutesDone pacifist",
|
||||
str(ctx.slot)+" RoutesDone genocide"]}])
|
||||
await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral",
|
||||
str(ctx.slot)+" RoutesDone pacifist",
|
||||
str(ctx.slot)+" RoutesDone genocide"]}])
|
||||
if args["slot_data"]["only_flakes"]:
|
||||
with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f:
|
||||
f.close()
|
||||
if not args["slot_data"]["key_hunt"]:
|
||||
ctx.pieces_needed = 0
|
||||
if args["slot_data"]["rando_love"]:
|
||||
filename = f"LOVErando.LV"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.close()
|
||||
if args["slot_data"]["rando_stats"]:
|
||||
filename = f"STATrando.LV"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.close()
|
||||
filename = f"{ctx.route}.route"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.close()
|
||||
filename = f"check.spot"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
|
||||
for ss in ctx.checked_locations:
|
||||
f.write(str(ss-12000)+"\n")
|
||||
f.close()
|
||||
elif cmd == "LocationInfo":
|
||||
for l in args["locations"]:
|
||||
locationid = l.location
|
||||
filename = f"{str(locationid-12000)}.hint"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
toDraw = ""
|
||||
for i in range(20):
|
||||
if i < len(str(ctx.item_names[l.item])):
|
||||
toDraw += str(ctx.item_names[l.item])[i]
|
||||
else:
|
||||
break
|
||||
f.write(toDraw)
|
||||
f.close()
|
||||
elif cmd == "Retrieved":
|
||||
if str(ctx.slot)+" RoutesDone neutral" in args["keys"]:
|
||||
if args["keys"][str(ctx.slot)+" RoutesDone neutral"] is not None:
|
||||
ctx.completed_routes["neutral"] = args["keys"][str(ctx.slot)+" RoutesDone neutral"]
|
||||
if str(ctx.slot)+" RoutesDone genocide" in args["keys"]:
|
||||
if args["keys"][str(ctx.slot)+" RoutesDone genocide"] is not None:
|
||||
ctx.completed_routes["genocide"] = args["keys"][str(ctx.slot)+" RoutesDone genocide"]
|
||||
if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]:
|
||||
if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None:
|
||||
ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"]
|
||||
elif cmd == "SetReply":
|
||||
if args["value"] is not None:
|
||||
if str(ctx.slot)+" RoutesDone pacifist" == args["key"]:
|
||||
ctx.completed_routes["pacifist"] = args["value"]
|
||||
elif str(ctx.slot)+" RoutesDone genocide" == args["key"]:
|
||||
ctx.completed_routes["genocide"] = args["value"]
|
||||
elif str(ctx.slot)+" RoutesDone neutral" == args["key"]:
|
||||
ctx.completed_routes["neutral"] = args["value"]
|
||||
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):
|
||||
counter = -1
|
||||
placedWeapon = 0
|
||||
placedArmor = 0
|
||||
for item in args["items"]:
|
||||
id = NetworkItem(*item).location
|
||||
while NetworkItem(*item).location < 0 and \
|
||||
counter <= id:
|
||||
id -= 1
|
||||
if NetworkItem(*item).location < 0:
|
||||
counter -= 1
|
||||
filename = f"{str(id)}PLR{str(NetworkItem(*item).player)}.item"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
if NetworkItem(*item).item == 77701:
|
||||
if placedWeapon == 0:
|
||||
f.write(str(77013-11000))
|
||||
elif placedWeapon == 1:
|
||||
f.write(str(77014-11000))
|
||||
elif placedWeapon == 2:
|
||||
f.write(str(77025-11000))
|
||||
elif placedWeapon == 3:
|
||||
f.write(str(77045-11000))
|
||||
elif placedWeapon == 4:
|
||||
f.write(str(77049-11000))
|
||||
elif placedWeapon == 5:
|
||||
f.write(str(77047-11000))
|
||||
elif placedWeapon == 6:
|
||||
if str(ctx.route) == "genocide" or str(ctx.route) == "all_routes":
|
||||
f.write(str(77052-11000))
|
||||
else:
|
||||
f.write(str(77051-11000))
|
||||
else:
|
||||
f.write(str(77003-11000))
|
||||
placedWeapon += 1
|
||||
elif NetworkItem(*item).item == 77702:
|
||||
if placedArmor == 0:
|
||||
f.write(str(77012-11000))
|
||||
elif placedArmor == 1:
|
||||
f.write(str(77015-11000))
|
||||
elif placedArmor == 2:
|
||||
f.write(str(77024-11000))
|
||||
elif placedArmor == 3:
|
||||
f.write(str(77044-11000))
|
||||
elif placedArmor == 4:
|
||||
f.write(str(77048-11000))
|
||||
elif placedArmor == 5:
|
||||
if str(ctx.route) == "genocide":
|
||||
f.write(str(77053-11000))
|
||||
else:
|
||||
f.write(str(77046-11000))
|
||||
elif placedArmor == 6 and ((not str(ctx.route) == "genocide") or ctx.tem_armor):
|
||||
if str(ctx.route) == "all_routes":
|
||||
f.write(str(77053-11000))
|
||||
elif str(ctx.route) == "genocide":
|
||||
f.write(str(77064-11000))
|
||||
else:
|
||||
f.write(str(77050-11000))
|
||||
elif placedArmor == 7 and ctx.tem_armor and not str(ctx.route) == "genocide":
|
||||
f.write(str(77064-11000))
|
||||
else:
|
||||
f.write(str(77004-11000))
|
||||
placedArmor += 1
|
||||
else:
|
||||
f.write(str(NetworkItem(*item).item-11000))
|
||||
f.close()
|
||||
ctx.items_received.append(NetworkItem(*item))
|
||||
if [item.item for item in ctx.items_received].count(77000) >= ctx.pieces_needed > 0:
|
||||
filename = f"{str(-99999)}PLR{str(0)}.item"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.write(str(77787 - 11000))
|
||||
f.close()
|
||||
filename = f"{str(-99998)}PLR{str(0)}.item"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.write(str(77789 - 11000))
|
||||
f.close()
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == "RoomUpdate":
|
||||
if "checked_locations" in args:
|
||||
filename = f"check.spot"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
|
||||
for ss in ctx.checked_locations:
|
||||
f.write(str(ss-12000)+"\n")
|
||||
f.close()
|
||||
|
||||
elif cmd == "Bounced":
|
||||
tags = args.get("tags", [])
|
||||
if "Online" in tags:
|
||||
data = args.get("worlds/undertale/data", {})
|
||||
if data["player"] != ctx.slot and data["player"] is not None:
|
||||
filename = f"FRISK" + str(data["player"]) + ".playerspot"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.write(str(data["x"]) + str(data["y"]) + str(data["room"]) + str(
|
||||
data["spr"]) + str(data["frm"]))
|
||||
f.close()
|
||||
|
||||
|
||||
async def multi_watcher(ctx: UndertaleContext):
|
||||
while not ctx.exit_event.is_set():
|
||||
path = ctx.save_game_folder
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if "spots.mine" in file and "Online" in ctx.tags:
|
||||
with open(root + "/" + file, "r") as mine:
|
||||
this_x = mine.readline()
|
||||
this_y = mine.readline()
|
||||
this_room = mine.readline()
|
||||
this_sprite = mine.readline()
|
||||
this_frame = mine.readline()
|
||||
mine.close()
|
||||
message = [{"cmd": "Bounce", "tags": ["Online"],
|
||||
"data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room,
|
||||
"spr": this_sprite, "frm": this_frame}}]
|
||||
await ctx.send_msgs(message)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
async def game_watcher(ctx: UndertaleContext):
|
||||
while not ctx.exit_event.is_set():
|
||||
await ctx.update_death_link(ctx.deathlink_status)
|
||||
path = ctx.save_game_folder
|
||||
if ctx.syncing:
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if ".item" in file:
|
||||
os.remove(root+"/"+file)
|
||||
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
|
||||
if ctx.got_deathlink:
|
||||
ctx.got_deathlink = False
|
||||
with open(os.path.join(ctx.save_game_folder, "/WelcomeToTheDead.youDied"), "w") as f:
|
||||
f.close()
|
||||
sending = []
|
||||
victory = False
|
||||
found_routes = 0
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if "DontBeMad.mad" in file and "DeathLink" in ctx.tags:
|
||||
os.remove(root+"/"+file)
|
||||
await ctx.send_death()
|
||||
if "scout" == file:
|
||||
sending = []
|
||||
with open(root+"/"+file, "r") as f:
|
||||
lines = f.readlines()
|
||||
for l in lines:
|
||||
if ctx.server_locations.__contains__(int(l)+12000):
|
||||
sending = sending + [int(l)+12000]
|
||||
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending,
|
||||
"create_as_hint": int(2)}])
|
||||
os.remove(root+"/"+file)
|
||||
if "check.spot" in file:
|
||||
sending = []
|
||||
with open(root+"/"+file, "r") as f:
|
||||
lines = f.readlines()
|
||||
for l in lines:
|
||||
sending = sending+[(int(l))+12000]
|
||||
message = [{"cmd": "LocationChecks", "locations": sending}]
|
||||
await ctx.send_msgs(message)
|
||||
if "victory" in file and str(ctx.route) in file:
|
||||
victory = True
|
||||
if ".playerspot" in file and "Online" not in ctx.tags:
|
||||
os.remove(root+"/"+file)
|
||||
if "victory" in file:
|
||||
if str(ctx.route) == "all_routes":
|
||||
if "neutral" in file and ctx.completed_routes["neutral"] != 1:
|
||||
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone neutral",
|
||||
"default": 0, "want_reply": True, "operations": [{"operation": "max",
|
||||
"value": 1}]}])
|
||||
elif "pacifist" in file and ctx.completed_routes["pacifist"] != 1:
|
||||
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone pacifist",
|
||||
"default": 0, "want_reply": True, "operations": [{"operation": "max",
|
||||
"value": 1}]}])
|
||||
elif "genocide" in file and ctx.completed_routes["genocide"] != 1:
|
||||
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone genocide",
|
||||
"default": 0, "want_reply": True, "operations": [{"operation": "max",
|
||||
"value": 1}]}])
|
||||
if str(ctx.route) == "all_routes":
|
||||
found_routes += ctx.completed_routes["neutral"]
|
||||
found_routes += ctx.completed_routes["pacifist"]
|
||||
found_routes += ctx.completed_routes["genocide"]
|
||||
if str(ctx.route) == "all_routes" and found_routes >= 3:
|
||||
victory = True
|
||||
ctx.locations_checked = sending
|
||||
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)
|
||||
|
||||
|
||||
def main():
|
||||
Utils.init_logging("UndertaleClient", exception_logger="Client")
|
||||
|
||||
async def _main():
|
||||
ctx = UndertaleContext(None, None)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
asyncio.create_task(
|
||||
game_watcher(ctx), name="UndertaleProgressionWatcher")
|
||||
|
||||
asyncio.create_task(
|
||||
multi_watcher(ctx), name="UndertaleMultiplayerWatcher")
|
||||
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
await ctx.shutdown()
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(_main())
|
||||
colorama.deinit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = get_base_parser(description="Undertale Client, for text interfacing.")
|
||||
args = parser.parse_args()
|
||||
main()
|
||||
445
WargrooveClient.py
Normal file
445
WargrooveClient.py
Normal file
@@ -0,0 +1,445 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import random
|
||||
import shutil
|
||||
from typing import Tuple, List, Iterable, Dict
|
||||
|
||||
from worlds.wargroove import WargrooveWorld
|
||||
from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
import json
|
||||
import logging
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("WargrooveClient", exception_logger="Client")
|
||||
|
||||
from NetUtils import NetworkItem, ClientStatus
|
||||
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
||||
CommonContext, server_loop
|
||||
|
||||
wg_logger = logging.getLogger("WG")
|
||||
|
||||
|
||||
class WargrooveClientCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.output(f"Syncing items.")
|
||||
self.ctx.syncing = True
|
||||
|
||||
def _cmd_commander(self, *commander_name: Iterable[str]):
|
||||
"""Set the current commander to the given commander."""
|
||||
if commander_name:
|
||||
self.ctx.set_commander(' '.join(commander_name))
|
||||
else:
|
||||
if self.ctx.can_choose_commander:
|
||||
commanders = self.ctx.get_commanders()
|
||||
wg_logger.info('Unlocked commanders: ' +
|
||||
', '.join((commander.name for commander, unlocked in commanders if unlocked)))
|
||||
wg_logger.info('Locked commanders: ' +
|
||||
', '.join((commander.name for commander, unlocked in commanders if not unlocked)))
|
||||
else:
|
||||
wg_logger.error('Cannot set commanders in this game mode.')
|
||||
|
||||
|
||||
class WargrooveContext(CommonContext):
|
||||
command_processor: int = WargrooveClientCommandProcessor
|
||||
game = "Wargroove"
|
||||
items_handling = 0b111 # full remote
|
||||
current_commander: CommanderData = faction_table["Starter"][0]
|
||||
can_choose_commander: bool = False
|
||||
commander_defense_boost_multiplier: int = 0
|
||||
income_boost_multiplier: int = 0
|
||||
starting_groove_multiplier: float
|
||||
faction_item_ids = {
|
||||
'Starter': 0,
|
||||
'Cherrystone': 52025,
|
||||
'Felheim': 52026,
|
||||
'Floran': 52027,
|
||||
'Heavensong': 52028,
|
||||
'Requiem': 52029,
|
||||
'Outlaw': 52030
|
||||
}
|
||||
buff_item_ids = {
|
||||
'Income Boost': 52023,
|
||||
'Commander Defense Boost': 52024,
|
||||
}
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(WargrooveContext, self).__init__(server_address, password)
|
||||
self.send_index: int = 0
|
||||
self.syncing = False
|
||||
self.awaiting_bridge = False
|
||||
# self.game_communication_path: files go in this path to pass data between us and the actual game
|
||||
if "appdata" in os.environ:
|
||||
options = Utils.get_options()
|
||||
root_directory = os.path.join(options["wargroove_options"]["root_directory"])
|
||||
data_directory = os.path.join("lib", "worlds", "wargroove", "data")
|
||||
dev_data_directory = os.path.join("worlds", "wargroove", "data")
|
||||
appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
|
||||
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
|
||||
print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
|
||||
"Unable to infer required game_communication_path")
|
||||
self.game_communication_path = os.path.join(root_directory, "AP")
|
||||
if not os.path.exists(self.game_communication_path):
|
||||
os.makedirs(self.game_communication_path)
|
||||
self.remove_communication_files()
|
||||
atexit.register(self.remove_communication_files)
|
||||
if not os.path.isdir(appdata_wargroove):
|
||||
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
|
||||
"Boot Wargroove and then close it to attempt to fix this error")
|
||||
if not os.path.isdir(data_directory):
|
||||
data_directory = dev_data_directory
|
||||
if not os.path.isdir(data_directory):
|
||||
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
|
||||
shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
|
||||
else:
|
||||
print_error_and_close("WargrooveClient couldn't detect system type. "
|
||||
"Unable to infer required game_communication_path")
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(WargrooveContext, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
async def connection_closed(self):
|
||||
await super(WargrooveContext, self).connection_closed()
|
||||
self.remove_communication_files()
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
if self.server:
|
||||
return [self.server]
|
||||
else:
|
||||
return []
|
||||
|
||||
async def shutdown(self):
|
||||
await super(WargrooveContext, self).shutdown()
|
||||
self.remove_communication_files()
|
||||
|
||||
def remove_communication_files(self):
|
||||
for root, dirs, files in os.walk(self.game_communication_path):
|
||||
for file in files:
|
||||
os.remove(root + "/" + file)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
filename = f"AP_settings.json"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
slot_data = args["slot_data"]
|
||||
json.dump(args["slot_data"], f)
|
||||
self.can_choose_commander = slot_data["can_choose_commander"]
|
||||
print('can choose commander:', self.can_choose_commander)
|
||||
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
|
||||
self.income_boost_multiplier = slot_data["income_boost"]
|
||||
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
|
||||
f.close()
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.close()
|
||||
self.update_commander_data()
|
||||
self.ui.update_tracker()
|
||||
|
||||
random.seed(self.seed_name + str(self.slot))
|
||||
# Our indexes start at 1 and we have 24 levels
|
||||
for i in range(1, 25):
|
||||
filename = f"seed{i}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.write(str(random.randint(0, 4294967295)))
|
||||
f.close()
|
||||
|
||||
if cmd in {"RoomInfo"}:
|
||||
self.seed_name = args["seed_name"]
|
||||
|
||||
if cmd in {"ReceivedItems"}:
|
||||
received_ids = [item.item for item in self.items_received]
|
||||
for network_item in self.items_received:
|
||||
filename = f"AP_{str(network_item.item)}.item"
|
||||
path = os.path.join(self.game_communication_path, filename)
|
||||
|
||||
# Newly-obtained items
|
||||
if not os.path.isfile(path):
|
||||
open(path, 'w').close()
|
||||
# Announcing commander unlocks
|
||||
item_name = self.item_names[network_item.item]
|
||||
if item_name in faction_table.keys():
|
||||
for commander in faction_table[item_name]:
|
||||
logger.info(f"{commander.name} has been unlocked!")
|
||||
|
||||
with open(path, 'w') as f:
|
||||
item_count = received_ids.count(network_item.item)
|
||||
if self.buff_item_ids["Income Boost"] == network_item.item:
|
||||
f.write(f"{item_count * self.income_boost_multiplier}")
|
||||
elif self.buff_item_ids["Commander Defense Boost"] == network_item.item:
|
||||
f.write(f"{item_count * self.commander_defense_boost_multiplier}")
|
||||
else:
|
||||
f.write(f"{item_count}")
|
||||
f.close()
|
||||
|
||||
print_filename = f"AP_{str(network_item.item)}.item.print"
|
||||
print_path = os.path.join(self.game_communication_path, print_filename)
|
||||
if not os.path.isfile(print_path):
|
||||
open(print_path, 'w').close()
|
||||
with open(print_path, 'w') as f:
|
||||
f.write("Received " +
|
||||
self.item_names[network_item.item] +
|
||||
" from " +
|
||||
self.player_names[network_item.player])
|
||||
f.close()
|
||||
self.update_commander_data()
|
||||
self.ui.update_tracker()
|
||||
|
||||
if cmd in {"RoomUpdate"}:
|
||||
if "checked_locations" in args:
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.close()
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager, HoverBehavior, ServerToolTip
|
||||
from kivy.uix.tabbedpanel import TabbedPanelItem
|
||||
from kivy.lang import Builder
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.togglebutton import ToggleButton
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.image import AsyncImage, Image
|
||||
from kivy.uix.stacklayout import StackLayout
|
||||
from kivy.uix.label import Label
|
||||
from kivy.properties import ColorProperty
|
||||
from kivy.uix.image import Image
|
||||
import pkgutil
|
||||
|
||||
class TrackerLayout(BoxLayout):
|
||||
pass
|
||||
|
||||
class CommanderSelect(BoxLayout):
|
||||
pass
|
||||
|
||||
class CommanderButton(ToggleButton):
|
||||
pass
|
||||
|
||||
class FactionBox(BoxLayout):
|
||||
pass
|
||||
|
||||
class CommanderGroup(BoxLayout):
|
||||
pass
|
||||
|
||||
class ItemTracker(BoxLayout):
|
||||
pass
|
||||
|
||||
class ItemLabel(Label):
|
||||
pass
|
||||
|
||||
class WargrooveManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("WG", "WG Console"),
|
||||
]
|
||||
base_title = "Archipelago Wargroove Client"
|
||||
ctx: WargrooveContext
|
||||
unit_tracker: ItemTracker
|
||||
trigger_tracker: BoxLayout
|
||||
boost_tracker: BoxLayout
|
||||
commander_buttons: Dict[int, List[CommanderButton]]
|
||||
tracker_items = {
|
||||
"Swordsman": ItemData(None, "Unit", False),
|
||||
"Dog": ItemData(None, "Unit", False),
|
||||
**item_table
|
||||
}
|
||||
|
||||
def build(self):
|
||||
container = super().build()
|
||||
panel = TabbedPanelItem(text="Wargroove")
|
||||
panel.content = self.build_tracker()
|
||||
self.tabs.add_widget(panel)
|
||||
return container
|
||||
|
||||
def build_tracker(self) -> TrackerLayout:
|
||||
try:
|
||||
tracker = TrackerLayout(orientation="horizontal")
|
||||
commander_select = CommanderSelect(orientation="vertical")
|
||||
self.commander_buttons = {}
|
||||
|
||||
for faction, commanders in faction_table.items():
|
||||
faction_box = FactionBox(size_hint=(None, None), width=100 * len(commanders), height=70)
|
||||
commander_group = CommanderGroup()
|
||||
commander_buttons = []
|
||||
for commander in commanders:
|
||||
commander_button = CommanderButton(text=commander.name, group="commanders")
|
||||
if faction == "Starter":
|
||||
commander_button.disabled = False
|
||||
commander_button.bind(on_press=lambda instance: self.ctx.set_commander(instance.text))
|
||||
commander_buttons.append(commander_button)
|
||||
commander_group.add_widget(commander_button)
|
||||
self.commander_buttons[faction] = commander_buttons
|
||||
faction_box.add_widget(Label(text=faction, size_hint_x=None, pos_hint={'left': 1}, size_hint_y=None, height=10))
|
||||
faction_box.add_widget(commander_group)
|
||||
commander_select.add_widget(faction_box)
|
||||
item_tracker = ItemTracker(padding=[0,20])
|
||||
self.unit_tracker = BoxLayout(orientation="vertical")
|
||||
other_tracker = BoxLayout(orientation="vertical")
|
||||
self.trigger_tracker = BoxLayout(orientation="vertical")
|
||||
self.boost_tracker = BoxLayout(orientation="vertical")
|
||||
other_tracker.add_widget(self.trigger_tracker)
|
||||
other_tracker.add_widget(self.boost_tracker)
|
||||
item_tracker.add_widget(self.unit_tracker)
|
||||
item_tracker.add_widget(other_tracker)
|
||||
tracker.add_widget(commander_select)
|
||||
tracker.add_widget(item_tracker)
|
||||
self.update_tracker()
|
||||
return tracker
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
def update_tracker(self):
|
||||
received_ids = [item.item for item in self.ctx.items_received]
|
||||
for faction, item_id in self.ctx.faction_item_ids.items():
|
||||
for commander_button in self.commander_buttons[faction]:
|
||||
commander_button.disabled = not (faction == "Starter" or item_id in received_ids)
|
||||
self.unit_tracker.clear_widgets()
|
||||
self.trigger_tracker.clear_widgets()
|
||||
for name, item in self.tracker_items.items():
|
||||
if item.type in ("Unit", "Trigger"):
|
||||
status_color = (1, 1, 1, 1) if item.code is None or item.code in received_ids else (0.6, 0.2, 0.2, 1)
|
||||
label = ItemLabel(text=name, color=status_color)
|
||||
if item.type == "Unit":
|
||||
self.unit_tracker.add_widget(label)
|
||||
else:
|
||||
self.trigger_tracker.add_widget(label)
|
||||
self.boost_tracker.clear_widgets()
|
||||
extra_income = received_ids.count(52023) * self.ctx.income_boost_multiplier
|
||||
extra_defense = received_ids.count(52024) * self.ctx.commander_defense_boost_multiplier
|
||||
income_boost = ItemLabel(text="Extra Income: " + str(extra_income))
|
||||
defense_boost = ItemLabel(text="Comm Defense: " + str(100 + extra_defense))
|
||||
self.boost_tracker.add_widget(income_boost)
|
||||
self.boost_tracker.add_widget(defense_boost)
|
||||
|
||||
self.ui = WargrooveManager(self)
|
||||
data = pkgutil.get_data(WargrooveWorld.__module__, "Wargroove.kv").decode()
|
||||
Builder.load_string(data)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def update_commander_data(self):
|
||||
if self.can_choose_commander:
|
||||
faction_items = 0
|
||||
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
|
||||
for network_item in self.items_received:
|
||||
if self.item_names[network_item.item] in faction_item_names:
|
||||
faction_items += 1
|
||||
starting_groove = (faction_items - 1) * self.starting_groove_multiplier
|
||||
# Must be an integer larger than 0
|
||||
starting_groove = int(max(starting_groove, 0))
|
||||
data = {
|
||||
"commander": self.current_commander.internal_name,
|
||||
"starting_groove": starting_groove
|
||||
}
|
||||
else:
|
||||
data = {
|
||||
"commander": "seed",
|
||||
"starting_groove": 0
|
||||
}
|
||||
filename = 'commander.json'
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
json.dump(data, f)
|
||||
if self.ui:
|
||||
self.ui.update_tracker()
|
||||
|
||||
def set_commander(self, commander_name: str) -> bool:
|
||||
"""Sets the current commander to the given one, if possible"""
|
||||
if not self.can_choose_commander:
|
||||
wg_logger.error("Cannot set commanders in this game mode.")
|
||||
return
|
||||
match_name = commander_name.lower()
|
||||
for commander, unlocked in self.get_commanders():
|
||||
if commander.name.lower() == match_name or commander.alt_name and commander.alt_name.lower() == match_name:
|
||||
if unlocked:
|
||||
self.current_commander = commander
|
||||
self.syncing = True
|
||||
wg_logger.info(f"Commander set to {commander.name}.")
|
||||
self.update_commander_data()
|
||||
return True
|
||||
else:
|
||||
wg_logger.error(f"Commander {commander.name} has not been unlocked.")
|
||||
return False
|
||||
else:
|
||||
wg_logger.error(f"{commander_name} is not a recognized Wargroove commander.")
|
||||
|
||||
def get_commanders(self) -> List[Tuple[CommanderData, bool]]:
|
||||
"""Gets a list of commanders with their unlocked status"""
|
||||
commanders = []
|
||||
received_ids = [item.item for item in self.items_received]
|
||||
for faction in faction_table.keys():
|
||||
unlocked = faction == 'Starter' or self.faction_item_ids[faction] in received_ids
|
||||
commanders += [(commander, unlocked) for commander in faction_table[faction]]
|
||||
return commanders
|
||||
|
||||
|
||||
async def game_watcher(ctx: WargrooveContext):
|
||||
from worlds.wargroove.Locations import location_table
|
||||
while not ctx.exit_event.is_set():
|
||||
if ctx.syncing == True:
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
ctx.syncing = False
|
||||
sending = []
|
||||
victory = False
|
||||
for root, dirs, files in os.walk(ctx.game_communication_path):
|
||||
for file in files:
|
||||
if file.find("send") > -1:
|
||||
st = file.split("send", -1)[1]
|
||||
sending = sending+[(int(st))]
|
||||
if file.find("victory") > -1:
|
||||
victory = True
|
||||
ctx.locations_checked = sending
|
||||
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
||||
await ctx.send_msgs(message)
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
def print_error_and_close(msg):
|
||||
logger.error("Error: " + msg)
|
||||
Utils.messagebox("Error", msg, error=True)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
async def main(args):
|
||||
ctx = WargrooveContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
progression_watcher = asyncio.create_task(
|
||||
game_watcher(ctx), name="WargrooveProgressionWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await progression_watcher
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
113
WebHost.py
113
WebHost.py
@@ -1,39 +1,136 @@
|
||||
import os
|
||||
import multiprocessing
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from WebHostLib import app as raw_app
|
||||
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__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
||||
|
||||
from WebHostLib import register, 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 = "config.yaml"
|
||||
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():
|
||||
register()
|
||||
app = raw_app
|
||||
if os.path.exists(configpath):
|
||||
if os.path.exists(configpath) and not app.config["TESTING"]:
|
||||
import yaml
|
||||
with open(configpath) as c:
|
||||
app.config.update(yaml.safe_load(c))
|
||||
|
||||
app.config.from_file(configpath, yaml.safe_load)
|
||||
logging.info(f"Updated config from {configpath}")
|
||||
if not app.config["HOST_ADDRESS"]:
|
||||
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
||||
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
|
||||
|
||||
db.bind(**app.config["PONY"])
|
||||
db.generate_mapping(create_tables=True)
|
||||
return app
|
||||
|
||||
|
||||
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
||||
import json
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
zfile: zipfile.ZipInfo
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
worlds = {}
|
||||
data = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
||||
worlds[game] = world
|
||||
|
||||
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
|
||||
for game, world in worlds.items():
|
||||
# copy files from world's docs folder to the generated folder
|
||||
target_path = os.path.join(base_target_path, game)
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
|
||||
if world.zip_path:
|
||||
zipfile_path = world.zip_path
|
||||
|
||||
assert os.path.isfile(zipfile_path), f"{zipfile_path} is not a valid file(path)."
|
||||
assert zipfile.is_zipfile(zipfile_path), f"{zipfile_path} is not a valid zipfile."
|
||||
|
||||
with zipfile.ZipFile(zipfile_path) as zf:
|
||||
for zfile in zf.infolist():
|
||||
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
||||
zf.extract(zfile, target_path)
|
||||
else:
|
||||
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
||||
files = os.listdir(source_path)
|
||||
for file in files:
|
||||
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
|
||||
|
||||
# build a json tutorial dict per game
|
||||
game_data = {'gameTitle': game, 'tutorials': []}
|
||||
for tutorial in world.web.tutorials:
|
||||
# build dict for the json file
|
||||
current_tutorial = {
|
||||
'name': tutorial.tutorial_name,
|
||||
'description': tutorial.description,
|
||||
'files': [{
|
||||
'language': tutorial.language,
|
||||
'filename': game + '/' + tutorial.file_name,
|
||||
'link': f'{game}/{tutorial.link}',
|
||||
'authors': tutorial.authors
|
||||
}]
|
||||
}
|
||||
|
||||
# check if the name of the current guide exists already
|
||||
for guide in game_data['tutorials']:
|
||||
if guide and tutorial.tutorial_name == guide['name']:
|
||||
guide['files'].append(current_tutorial['files'][0])
|
||||
break
|
||||
else:
|
||||
game_data['tutorials'].append(current_tutorial)
|
||||
|
||||
data.append(game_data)
|
||||
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
|
||||
generic_data = {}
|
||||
for games in data:
|
||||
if 'Archipelago' in games['gameTitle']:
|
||||
generic_data = data.pop(data.index(games))
|
||||
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
|
||||
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
||||
return sorted_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
multiprocessing.set_start_method('spawn')
|
||||
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)
|
||||
app.run(debug=True, port=app.config["PORT"])
|
||||
else:
|
||||
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
|
||||
|
||||
46
WebHostLib/README.md
Normal file
46
WebHostLib/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# WebHost
|
||||
|
||||
## Contribution Guidelines
|
||||
**Thank you for your interest in contributing to the Archipelago website!**
|
||||
Much of the content on the website is generated automatically, but there are some things
|
||||
that need a personal touch. For those things, we rely on contributions from both the core
|
||||
team and the community. The current primary maintainer of the website is Farrak Kilhn.
|
||||
He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`.
|
||||
|
||||
### Small Changes
|
||||
Little changes like adding a button or a couple new select elements are perfectly fine.
|
||||
Tweaks to style specific to a PR's content are also probably not a problem. For example, if
|
||||
you build a new page which needs two side by side tables, and you need to write a CSS file
|
||||
specific to your page, that is perfectly reasonable.
|
||||
|
||||
### Content Additions
|
||||
Once you develop a new feature or add new content the website, make a pull request. It will
|
||||
be reviewed by the community and there will probably be some discussion around it. Depending
|
||||
on the size of the feature, and if new styles are required, there may be an additional step
|
||||
before the PR is accepted wherein Farrak works with the designer to implement styles.
|
||||
|
||||
### Restrictions on Style Changes
|
||||
A professional designer is paid to develop the styles and assets for the Archipelago website.
|
||||
In an effort to maintain a consistent look and feel, pull requests which *exclusively*
|
||||
change site styles are rejected. Please note this applies to code which changes the overall
|
||||
look and feel of the site, not to small tweaks to CSS for your custom page. The intention
|
||||
behind these restrictions is to maintain a curated feel for the design of the site. If
|
||||
any PR affects the overall feel of the site but includes additive changes, there will
|
||||
likely be a conversation about how to implement those changes without compromising the
|
||||
curated site style. It is therefore worth noting there are a couple files which, if
|
||||
changed in your pull request, will cause it to draw additional scrutiny.
|
||||
|
||||
These closely guarded files are:
|
||||
- `globalStyles.css`
|
||||
- `islandFooter.css`
|
||||
- `landing.css`
|
||||
- `markdown.css`
|
||||
- `tooltip.css`
|
||||
|
||||
### Site Themes
|
||||
There are several themes available for game pages. It is possible to request a new theme in
|
||||
the `#art-and-design` channel on Discord. Because themes are created by the designer, they
|
||||
are not free, and take some time to create. Farrak works closely with the designer to implement
|
||||
these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year
|
||||
are added. If a proposed theme seems like a cool idea and the community likes it, there is a
|
||||
good chance it will become a reality.
|
||||
@@ -1,15 +1,15 @@
|
||||
import os
|
||||
import uuid
|
||||
import base64
|
||||
import os
|
||||
import socket
|
||||
import uuid
|
||||
|
||||
from pony.flask import Pony
|
||||
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||
from flask import Flask
|
||||
from flask_caching import Cache
|
||||
from flaskext.autoversion import Autoversion
|
||||
from flask_compress import Compress
|
||||
from pony.flask import Pony
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from .models import *
|
||||
from Utils import title_sorted
|
||||
|
||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||
LOGS_FOLDER = os.path.relpath('logs')
|
||||
@@ -21,17 +21,22 @@ 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["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
|
||||
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
||||
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
|
||||
app.config["JOB_THRESHOLD"] = 2
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
||||
app.config["JOB_THRESHOLD"] = 1
|
||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||
app.config["JOB_TIME"] = 600
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
|
||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||
@@ -44,18 +49,13 @@ app.config["PONY"] = {
|
||||
'create_db': True
|
||||
}
|
||||
app.config["MAX_ROLL"] = 20
|
||||
app.config["CACHE_TYPE"] = "simple"
|
||||
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
|
||||
app.config["JSON_AS_ASCII"] = False
|
||||
app.config["PATCH_TARGET"] = "archipelago.gg"
|
||||
app.config["HOST_ADDRESS"] = ""
|
||||
|
||||
app.autoversion = True
|
||||
|
||||
av = Autoversion(app)
|
||||
cache = Cache(app)
|
||||
Compress(app)
|
||||
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
|
||||
class B64UUIDConverter(BaseConverter):
|
||||
|
||||
@@ -69,92 +69,20 @@ class B64UUIDConverter(BaseConverter):
|
||||
# short UUID
|
||||
app.url_map.converters["suuid"] = B64UUIDConverter
|
||||
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||
app.jinja_env.filters["title_sorted"] = title_sorted
|
||||
|
||||
|
||||
@app.before_request
|
||||
def register_session():
|
||||
session.permanent = True # technically 31 days after the last visit
|
||||
if not session.get("_id", None):
|
||||
session["_id"] = uuid4() # uniquely identify each session without needing a login
|
||||
def register():
|
||||
"""Import submodules, triggering their registering on flask routing.
|
||||
Note: initializes worlds subsystem."""
|
||||
# has automatic patch integration
|
||||
import worlds.AutoWorld
|
||||
import worlds.Files
|
||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
|
||||
game_name in worlds.Files.AutoPatchRegister.patch_types
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
# to trigger app routing picking up on it
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@app.route('/tutorial')
|
||||
def tutorial_landing():
|
||||
return render_template("tutorialLanding.html")
|
||||
|
||||
|
||||
@app.route('/player-settings')
|
||||
def player_settings_simple():
|
||||
return render_template("playerSettings.html")
|
||||
|
||||
|
||||
@app.route('/weighted-settings')
|
||||
def player_settings():
|
||||
return render_template("weightedSettings.html")
|
||||
|
||||
|
||||
@app.route('/seed/<suuid:seed>')
|
||||
def viewSeed(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"]])
|
||||
|
||||
|
||||
@app.route('/new_room/<suuid:seed>')
|
||||
def new_room(seed: UUID):
|
||||
seed = Seed.get(id=seed)
|
||||
if not seed:
|
||||
abort(404)
|
||||
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
|
||||
commit()
|
||||
return redirect(url_for("hostRoom", room=room.id))
|
||||
|
||||
|
||||
def _read_log(path: str):
|
||||
if os.path.exists(path):
|
||||
with open(path, encoding="utf-8-sig") as log:
|
||||
yield from log
|
||||
else:
|
||||
yield f"Logfile {path} does not exist. " \
|
||||
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
|
||||
|
||||
|
||||
@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):
|
||||
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()
|
||||
|
||||
with db_session:
|
||||
room.last_activity = datetime.utcnow() # will trigger a spinup, if it's not already running
|
||||
|
||||
return render_template("hostRoom.html", room=room)
|
||||
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
return send_from_directory(os.path.join(app.root_path, 'static/static'),
|
||||
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
||||
|
||||
|
||||
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)
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -1,24 +1,59 @@
|
||||
"""API endpoints package."""
|
||||
from typing import List, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Blueprint, abort
|
||||
|
||||
from ..models import Room
|
||||
from .. import cache
|
||||
from ..models import Room, Seed
|
||||
|
||||
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_datapackage():
|
||||
from worlds import network_data_package
|
||||
return network_data_package
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage_version')
|
||||
@cache.cached()
|
||||
def get_datapackage_versions():
|
||||
from worlds import AutoWorldRegister
|
||||
|
||||
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||
return version_package
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage_checksum')
|
||||
@cache.cached()
|
||||
def get_datapackage_checksums():
|
||||
from worlds import network_data_package
|
||||
version_package = {
|
||||
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
|
||||
}
|
||||
return version_package
|
||||
|
||||
|
||||
from . import generate, user # trigger registration
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import json
|
||||
import pickle
|
||||
from uuid import UUID
|
||||
|
||||
from . import api_endpoints
|
||||
from flask import request, session, url_for
|
||||
from markupsafe import Markup
|
||||
from pony.orm import commit
|
||||
|
||||
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||
from WebHostLib import app
|
||||
from WebHostLib.check import get_yaml_data, roll_options
|
||||
from WebHostLib.generate import get_meta
|
||||
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||
from . import api_endpoints
|
||||
|
||||
|
||||
@api_endpoints.route('/generate', methods=['POST'])
|
||||
@@ -14,22 +18,30 @@ def generate_api():
|
||||
try:
|
||||
options = {}
|
||||
race = False
|
||||
|
||||
meta_options_source = {}
|
||||
if 'file' in request.files:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
if isinstance(options, Markup):
|
||||
return {"text": options.striptags()}, 400
|
||||
if isinstance(options, str):
|
||||
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 is optional, we can have it silently fall to None as it used to do.
|
||||
# See https://flask.palletsprojects.com/en/2.2.x/api/#flask.Request.get_json -> Changelog -> 2.1
|
||||
json_data = request.get_json(silent=True)
|
||||
|
||||
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
|
||||
@@ -37,8 +49,8 @@ def generate_api():
|
||||
if len(options) > app.config["MAX_ROLL"]:
|
||||
return {"text": "Max size of multiworld exceeded",
|
||||
"detail": app.config["MAX_ROLL"]}, 409
|
||||
|
||||
results, gen_options = roll_options(options)
|
||||
meta = get_meta(meta_options_source, race)
|
||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return {"text": str(results),
|
||||
"detail": results}, 400
|
||||
@@ -46,7 +58,7 @@ def generate_api():
|
||||
gen = Generation(
|
||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||
# convert to json compatible
|
||||
meta=pickle.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.",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from flask import session, jsonify
|
||||
from pony.orm import select
|
||||
|
||||
from WebHostLib.models import *
|
||||
from . import api_endpoints
|
||||
from WebHostLib.models import Room, Seed
|
||||
from . import api_endpoints, get_players
|
||||
|
||||
|
||||
@api_endpoints.route('/get_rooms')
|
||||
@@ -16,7 +17,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 +28,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)
|
||||
@@ -1,21 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
from datetime import timedelta, datetime
|
||||
import concurrent.futures
|
||||
import os
|
||||
import sys
|
||||
import typing
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from pony.orm import db_session, select, commit
|
||||
|
||||
from Utils import restricted_loads
|
||||
|
||||
|
||||
class CommonLocker():
|
||||
"""Uses a file lock to signal that something is already running"""
|
||||
lock_folder = "file_locks"
|
||||
|
||||
def __init__(self, lockname: str):
|
||||
def __init__(self, lockname: str, folder=None):
|
||||
if folder:
|
||||
self.lock_folder = folder
|
||||
os.makedirs(self.lock_folder, exist_ok=True)
|
||||
self.lockname = lockname
|
||||
self.lockfile = f"./{self.lockname}.lck"
|
||||
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
|
||||
|
||||
|
||||
class AlreadyRunningException(Exception):
|
||||
@@ -23,8 +32,6 @@ class AlreadyRunningException(Exception):
|
||||
|
||||
|
||||
if sys.platform == 'win32':
|
||||
import os
|
||||
|
||||
class Locker(CommonLocker):
|
||||
def __enter__(self):
|
||||
try:
|
||||
@@ -43,11 +50,12 @@ if sys.platform == 'win32':
|
||||
else: # unix
|
||||
import fcntl
|
||||
|
||||
|
||||
class Locker(CommonLocker):
|
||||
def __enter__(self):
|
||||
try:
|
||||
self.fp = open(self.lockfile, "wb")
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX)
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
@@ -78,14 +86,21 @@ def handle_generation_failure(result: BaseException):
|
||||
|
||||
|
||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||
options = generation.options
|
||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||
|
||||
meta = generation.meta
|
||||
pool.apply_async(gen_game, (options,),
|
||||
{"race": meta["race"], "sid": generation.id, "owner": generation.owner},
|
||||
handle_generation_success, handle_generation_failure)
|
||||
generation.state = STATE_STARTED
|
||||
try:
|
||||
meta = json.loads(generation.meta)
|
||||
options = restricted_loads(generation.options)
|
||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||
pool.apply_async(gen_game, (options,),
|
||||
{"meta": meta,
|
||||
"sid": generation.id,
|
||||
"owner": generation.owner},
|
||||
handle_generation_success, handle_generation_failure)
|
||||
except Exception as e:
|
||||
generation.state = STATE_ERROR
|
||||
commit()
|
||||
logging.exception(e)
|
||||
else:
|
||||
generation.state = STATE_STARTED
|
||||
|
||||
|
||||
def init_db(pony_config: dict):
|
||||
@@ -97,9 +112,30 @@ def autohost(config: dict):
|
||||
def keep_running():
|
||||
try:
|
||||
with Locker("autohost"):
|
||||
run_guardian()
|
||||
while 1:
|
||||
time.sleep(0.1)
|
||||
with db_session:
|
||||
rooms = select(
|
||||
room for room in Room if
|
||||
room.last_activity >= datetime.utcnow() - timedelta(days=3))
|
||||
for room in rooms:
|
||||
launch_room(room, config)
|
||||
|
||||
except AlreadyRunningException:
|
||||
logging.info("Autohost reports as already running, not starting another.")
|
||||
|
||||
import threading
|
||||
threading.Thread(target=keep_running, name="AP_Autohost").start()
|
||||
|
||||
|
||||
def autogen(config: dict):
|
||||
def keep_running():
|
||||
try:
|
||||
with Locker("autogen"):
|
||||
|
||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
|
||||
initargs=(config["PONY"],)) as generator_pool:
|
||||
initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
|
||||
with db_session:
|
||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||
|
||||
@@ -116,57 +152,95 @@ def autohost(config: dict):
|
||||
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
||||
|
||||
while 1:
|
||||
time.sleep(0.50)
|
||||
time.sleep(0.1)
|
||||
with db_session:
|
||||
rooms = select(
|
||||
room for room in Room if
|
||||
room.last_activity >= datetime.utcnow() - timedelta(days=3))
|
||||
for room in rooms:
|
||||
launch_room(room, config)
|
||||
# for update locks the database row(s) during transaction, preventing writes from elsewhere
|
||||
to_start = select(
|
||||
generation for generation in Generation if generation.state == STATE_QUEUED)
|
||||
generation for generation in Generation
|
||||
if generation.state == STATE_QUEUED).for_update()
|
||||
for generation in to_start:
|
||||
launch_generator(generator_pool, generation)
|
||||
except AlreadyRunningException:
|
||||
pass
|
||||
logging.info("Autogen reports as already running, not starting another.")
|
||||
|
||||
import threading
|
||||
threading.Thread(target=keep_running).start()
|
||||
threading.Thread(target=keep_running, name="AP_Autogen").start()
|
||||
|
||||
|
||||
multiworlds = {}
|
||||
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
||||
|
||||
guardians = concurrent.futures.ThreadPoolExecutor(2, thread_name_prefix="Guardian")
|
||||
|
||||
class MultiworldInstance():
|
||||
def __init__(self, room: Room, config: dict):
|
||||
self.room_id = room.id
|
||||
self.process: typing.Optional[multiprocessing.Process] = None
|
||||
multiworlds[self.room_id] = self
|
||||
with guardian_lock:
|
||||
multiworlds[self.room_id] = self
|
||||
self.ponyconfig = config["PONY"]
|
||||
self.cert = config["SELFLAUNCHCERT"]
|
||||
self.key = config["SELFLAUNCHKEY"]
|
||||
self.host = config["HOST_ADDRESS"]
|
||||
|
||||
def start(self):
|
||||
if self.process and self.process.is_alive():
|
||||
return False
|
||||
|
||||
logging.info(f"Spinning up {self.room_id}")
|
||||
self.process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.room_id, self.ponyconfig),
|
||||
name="MultiHost")
|
||||
self.process.start()
|
||||
self.guardian = guardians.submit(self._collect)
|
||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.room_id, self.ponyconfig, get_static_server_data(),
|
||||
self.cert, self.key, self.host),
|
||||
name="MultiHost")
|
||||
process.start()
|
||||
# bind after start to prevent thread sync issues with guardian.
|
||||
self.process = process
|
||||
|
||||
def stop(self):
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
self.process = None
|
||||
|
||||
def _collect(self):
|
||||
self.process.join() # wait for process to finish
|
||||
def done(self):
|
||||
return self.process and not self.process.is_alive()
|
||||
|
||||
def collect(self):
|
||||
self.process.join() # wait for process to finish
|
||||
self.process = None
|
||||
self.guardian = None
|
||||
|
||||
|
||||
guardian = None
|
||||
guardian_lock = threading.Lock()
|
||||
|
||||
|
||||
def run_guardian():
|
||||
global guardian
|
||||
global multiworlds
|
||||
with guardian_lock:
|
||||
if not guardian:
|
||||
try:
|
||||
import resource
|
||||
except ModuleNotFoundError:
|
||||
pass # unix only module
|
||||
else:
|
||||
# Each Server is another file handle, so request as many as we can from the system
|
||||
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
|
||||
# set soft limit to hard limit
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
|
||||
|
||||
def guard():
|
||||
while 1:
|
||||
time.sleep(1)
|
||||
done = []
|
||||
with guardian_lock:
|
||||
for key, instance in multiworlds.items():
|
||||
if instance.done():
|
||||
instance.collect()
|
||||
done.append(key)
|
||||
for key in done:
|
||||
del (multiworlds[key])
|
||||
|
||||
guardian = threading.Thread(name="Guardian", target=guard)
|
||||
|
||||
|
||||
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
|
||||
from .customserver import run_server_process
|
||||
from .customserver import run_server_process, get_static_server_data
|
||||
from .generate import gen_game
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import zipfile
|
||||
from typing import *
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
from flask import request, flash, redirect, url_for, render_template
|
||||
from markupsafe import Markup
|
||||
|
||||
from WebHostLib import app
|
||||
|
||||
@@ -12,12 +13,12 @@ def allowed_file(filename):
|
||||
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||
|
||||
|
||||
from Mystery import roll_settings
|
||||
from Utils import parse_yaml
|
||||
from Generate import roll_settings, PlandoOptions
|
||||
from Utils import parse_yamls
|
||||
|
||||
|
||||
@app.route('/mysterycheck', methods=['GET', 'POST'])
|
||||
def mysterycheck():
|
||||
@app.route('/check', methods=['GET', 'POST'])
|
||||
def check():
|
||||
if request.method == 'POST':
|
||||
# check if the post request has the file part
|
||||
if 'file' not in request.files:
|
||||
@@ -25,16 +26,20 @@ def mysterycheck():
|
||||
else:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
if isinstance(options, str):
|
||||
flash(options)
|
||||
else:
|
||||
results, _ = roll_options(options)
|
||||
return render_template("checkResult.html", results=results)
|
||||
|
||||
return render_template("check.html")
|
||||
|
||||
|
||||
def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
@app.route('/mysterycheck')
|
||||
def mysterycheck():
|
||||
return redirect(url_for("check"), 301)
|
||||
|
||||
|
||||
def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
|
||||
options = {}
|
||||
# if user does not select file, browser also
|
||||
# submit an empty part without filename
|
||||
@@ -46,12 +51,15 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
with zipfile.ZipFile(file, 'r') as zfile:
|
||||
infolist = zfile.infolist()
|
||||
|
||||
if any(file.filename.endswith(".archipelago") for file in infolist):
|
||||
return Markup("Error: Your .zip file contains an .archipelago file. "
|
||||
'Did you mean to <a href="/uploads">host a game</a>?')
|
||||
|
||||
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"):
|
||||
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
|
||||
"Your file was deleted."
|
||||
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
|
||||
options[file.filename] = zfile.open(file, "r").read()
|
||||
else:
|
||||
options = {file.filename: file.read()}
|
||||
@@ -60,22 +68,31 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
return options
|
||||
|
||||
|
||||
def roll_options(options: Dict[str, Union[dict, str]]) -> Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||
def roll_options(options: Dict[str, Union[dict, str]],
|
||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||
plando_options = PlandoOptions.from_set(set(plando_options))
|
||||
results = {}
|
||||
rolled_results = {}
|
||||
for filename, text in options.items():
|
||||
try:
|
||||
if type(text) is dict:
|
||||
yaml_data = text
|
||||
yaml_datas = (text, )
|
||||
else:
|
||||
yaml_data = parse_yaml(text)
|
||||
yaml_datas = tuple(parse_yamls(text))
|
||||
except Exception as e:
|
||||
results[filename] = f"Failed to parse YAML data in {filename}: {e}"
|
||||
else:
|
||||
try:
|
||||
rolled_results[filename] = roll_settings(yaml_data, plando_options={"bosses"})
|
||||
if len(yaml_datas) == 1:
|
||||
rolled_results[filename] = roll_settings(yaml_datas[0],
|
||||
plando_options=plando_options)
|
||||
else:
|
||||
for i, yaml_data in enumerate(yaml_datas):
|
||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||
plando_options=plando_options)
|
||||
except Exception as e:
|
||||
results[filename] = f"Failed to generate mystery in {filename}: {e}"
|
||||
results[filename] = f"Failed to generate options in {filename}: {e}"
|
||||
else:
|
||||
results[filename] = True
|
||||
return results, rolled_results
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import datetime
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import websockets
|
||||
import asyncio
|
||||
import pickle
|
||||
import random
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import random
|
||||
import pickle
|
||||
import typing
|
||||
|
||||
import websockets
|
||||
from pony.orm import commit, db_session, select
|
||||
|
||||
from .models import *
|
||||
import Utils
|
||||
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
||||
from Utils import restricted_loads, cache_argsless
|
||||
from .models import Command, GameDataPackage, Room, db
|
||||
|
||||
|
||||
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,8 +42,9 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
|
||||
# inject
|
||||
import MultiServer
|
||||
|
||||
MultiServer.client_message_processor = CustomClientMessageProcessor
|
||||
del (MultiServer)
|
||||
del MultiServer
|
||||
|
||||
|
||||
class DBCommandProcessor(ServerCommandProcessor):
|
||||
@@ -47,16 +53,27 @@ class DBCommandProcessor(ServerCommandProcessor):
|
||||
|
||||
|
||||
class WebHostContext(Context):
|
||||
def __init__(self):
|
||||
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", 0, 2)
|
||||
room_id: int
|
||||
|
||||
def __init__(self, static_server_data: dict):
|
||||
# static server data is used during _load_game_data to load required data,
|
||||
# without needing to import worlds system, which takes quite a bit of memory
|
||||
self.static_server_data = static_server_data
|
||||
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
|
||||
del self.static_server_data
|
||||
self.main_loop = asyncio.get_running_loop()
|
||||
self.video = {}
|
||||
self.tags = ["AP", "WebHost"]
|
||||
|
||||
def _load_game_data(self):
|
||||
for key, value in self.static_server_data.items():
|
||||
setattr(self, key, value)
|
||||
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||
|
||||
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 +92,21 @@ class WebHostContext(Context):
|
||||
else:
|
||||
self.port = get_random_port()
|
||||
|
||||
return self._load(self._decompress(room.seed.multidata), True)
|
||||
multidata = self.decompress(room.seed.multidata)
|
||||
game_data_packages = {}
|
||||
for game in list(multidata.get("datapackage", {})):
|
||||
game_data = multidata["datapackage"][game]
|
||||
if "checksum" in game_data:
|
||||
if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
||||
# non-custom. remove from multidata
|
||||
# games package could be dropped from static data once all rooms embed data package
|
||||
del multidata["datapackage"][game]
|
||||
else:
|
||||
row = GameDataPackage.get(checksum=game_data["checksum"])
|
||||
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
||||
game_data_packages[game] = Utils.restricted_loads(row.data)
|
||||
|
||||
return self._load(multidata, game_data_packages, True)
|
||||
|
||||
@db_session
|
||||
def init_save(self, enabled: bool = True):
|
||||
@@ -88,12 +119,12 @@ 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
|
||||
room.last_activity = datetime.utcnow()
|
||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||
room.last_activity = datetime.datetime.utcnow()
|
||||
return True
|
||||
|
||||
def get_save(self) -> dict:
|
||||
@@ -101,50 +132,85 @@ 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)
|
||||
|
||||
def run_server_process(room_id, ponyconfig: dict):
|
||||
|
||||
@cache_argsless
|
||||
def get_static_server_data() -> dict:
|
||||
import worlds
|
||||
data = {
|
||||
"non_hintable_names": {},
|
||||
"gamespackage": worlds.network_data_package["games"],
|
||||
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()},
|
||||
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()},
|
||||
}
|
||||
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
data["non_hintable_names"][world_name] = world.hint_blacklist
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
|
||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||
host: str):
|
||||
# establish DB connection for multidata and multisave
|
||||
db.bind(**ponyconfig)
|
||||
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')])
|
||||
ctx = WebHostContext()
|
||||
Utils.init_logging(str(room_id), write_mode="a")
|
||||
ctx = WebHostContext(static_server_data)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
|
||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
||||
try:
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
|
||||
ping_interval=None)
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||
|
||||
await ctx.server
|
||||
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None,
|
||||
ping_interval=None)
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
||||
|
||||
await ctx.server
|
||||
port = 0
|
||||
for wssocket in ctx.server.ws_server.sockets:
|
||||
socketname = wssocket.getsockname()
|
||||
if wssocket.family == socket.AF_INET6:
|
||||
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = socketname[1]
|
||||
# Prefer IPv4, as most users seem to not have working ipv6 support
|
||||
if not port:
|
||||
port = socketname[1]
|
||||
elif wssocket.family == socket.AF_INET:
|
||||
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
|
||||
port = socketname[1]
|
||||
if port:
|
||||
logging.info(f'Hosting game at {host}:{port}')
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = port
|
||||
else:
|
||||
logging.exception("Could not determine port. Likely hosting failure.")
|
||||
with db_session:
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
await ctx.shutdown_task
|
||||
logging.info("Shutting down")
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
from WebHostLib import LOGS_FOLDER
|
||||
from .autolauncher import Locker
|
||||
with Locker(room_id):
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
except:
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
room.last_port = -1
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
raise
|
||||
|
||||
@@ -1,26 +1,48 @@
|
||||
from flask import send_file, Response
|
||||
import json
|
||||
import zipfile
|
||||
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, Patch, Room, Seed
|
||||
from worlds.Files import AutoPatchRegister
|
||||
from . import app, cache
|
||||
from .models import Slot, Room, Seed
|
||||
|
||||
|
||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||
def download_patch(room_id, patch_id):
|
||||
patch = Patch.get(id=patch_id)
|
||||
patch = Slot.get(id=patch_id)
|
||||
if not patch:
|
||||
return "Patch not found"
|
||||
else:
|
||||
import io
|
||||
|
||||
room = Room.get(id=room_id)
|
||||
last_port = room.last_port
|
||||
|
||||
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)}.apbp"
|
||||
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
|
||||
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['HOST_ADDRESS']}:{last_port}" if last_port else None
|
||||
with zipfile.ZipFile(new_file, "w") as new_zip:
|
||||
for file in zf.infolist():
|
||||
if file.filename == "archipelago.json":
|
||||
new_zip.writestr("archipelago.json", json.dumps(manifest))
|
||||
else:
|
||||
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
|
||||
if "patch_file_ending" in manifest:
|
||||
patch_file_ending = manifest["patch_file_ending"]
|
||||
else:
|
||||
patch_file_ending = AutoPatchRegister.patch_types[patch.game].patch_file_ending
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \
|
||||
f"{patch_file_ending}"
|
||||
new_file.seek(0)
|
||||
return send_file(new_file, as_attachment=True, download_name=fname)
|
||||
else:
|
||||
return "Old Patch file, no longer compatible."
|
||||
|
||||
|
||||
@app.route("/dl_spoiler/<suuid:seed_id>")
|
||||
@@ -28,18 +50,57 @@ 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):
|
||||
patch = select(patch for patch in Patch if
|
||||
patch.player_id == player_id and patch.seed.id == seed_id).first()
|
||||
@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 patch:
|
||||
return "Patch not found"
|
||||
if not slot_data:
|
||||
return "Slot Data not found"
|
||||
else:
|
||||
import io
|
||||
|
||||
patch_data = update_patch_data(patch.data, server="")
|
||||
patch_data = io.BytesIO(patch_data)
|
||||
if slot_data.game == "Minecraft":
|
||||
from worlds.minecraft import mc_update_output
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
||||
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
|
||||
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
|
||||
elif slot_data.game == "Factorio":
|
||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith("info.json"):
|
||||
fname = name.rsplit("/", 1)[0] + ".zip"
|
||||
elif slot_data.game == "Ocarina of Time":
|
||||
stream = io.BytesIO(slot_data.data)
|
||||
if zipfile.is_zipfile(stream):
|
||||
with zipfile.ZipFile(stream) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith(".zpf"):
|
||||
fname = name.rsplit(".", 1)[0] + ".apz5"
|
||||
else: # pre-ootr-7.0 support
|
||||
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 == "Zillion":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
|
||||
elif slot_data.game == "Super Mario 64":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
||||
elif slot_data.game == "Dark Souls III":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
|
||||
elif slot_data.game == "Kingdom Hearts 2":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
|
||||
else:
|
||||
return "Game download not supported."
|
||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
|
||||
|
||||
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("/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)
|
||||
|
||||
@@ -1,19 +1,59 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import pickle
|
||||
import random
|
||||
import tempfile
|
||||
import zipfile
|
||||
import concurrent.futures
|
||||
from collections import Counter
|
||||
from typing import Dict, Optional, Any, Union, List
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
from pony.orm import commit, db_session
|
||||
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
from Generate import handle_name, PlandoOptions
|
||||
from Main import main as ERmain
|
||||
from Main import get_seed, seeddigits
|
||||
from Mystery import handle_name
|
||||
import pickle
|
||||
|
||||
from .models import *
|
||||
from Utils import __version__
|
||||
from WebHostLib import app
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from .check import get_yaml_data, roll_options
|
||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
||||
from .upload import upload_zip_to_db
|
||||
|
||||
|
||||
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
|
||||
plando_options = {
|
||||
options_source.get("plando_bosses", ""),
|
||||
options_source.get("plando_items", ""),
|
||||
options_source.get("plando_connections", ""),
|
||||
options_source.get("plando_texts", "")
|
||||
}
|
||||
plando_options -= {""}
|
||||
|
||||
server_options = {
|
||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||
"release_mode": options_source.get("release_mode", "goal"),
|
||||
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||
"server_password": options_source.get("server_password", None),
|
||||
}
|
||||
generator_options = {
|
||||
"spoiler": int(options_source.get("spoiler", 0)),
|
||||
"race": race
|
||||
}
|
||||
|
||||
if race:
|
||||
server_options["item_cheat"] = False
|
||||
server_options["remaining_mode"] = "disabled"
|
||||
generator_options["spoiler"] = 0
|
||||
|
||||
return {
|
||||
"server_options": server_options,
|
||||
"plando_options": list(plando_options),
|
||||
"generator_options": generator_options,
|
||||
}
|
||||
|
||||
|
||||
@app.route('/generate', methods=['GET', 'POST'])
|
||||
@@ -26,20 +66,23 @@ def generate(race=False):
|
||||
else:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
if isinstance(options, str):
|
||||
flash(options)
|
||||
else:
|
||||
results, gen_options = roll_options(options)
|
||||
meta = get_meta(request.form, race)
|
||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return render_template("checkResult.html", results=results)
|
||||
elif len(gen_options) > app.config["MAX_ROLL"]:
|
||||
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players for now. "
|
||||
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
|
||||
f"If you have a larger group, please generate it yourself and upload it.")
|
||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||
gen = Generation(
|
||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||
# convert to json compatible
|
||||
meta=pickle.dumps({"race": race}), state=STATE_QUEUED,
|
||||
meta=json.dumps(meta),
|
||||
state=STATE_QUEUED,
|
||||
owner=session["_id"])
|
||||
commit()
|
||||
|
||||
@@ -47,67 +90,91 @@ 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):
|
||||
try:
|
||||
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||
if not meta:
|
||||
meta: Dict[str, Any] = {}
|
||||
|
||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
||||
|
||||
def task():
|
||||
target = tempfile.TemporaryDirectory()
|
||||
playercount = len(gen_options)
|
||||
seed = get_seed()
|
||||
random.seed(seed)
|
||||
|
||||
if race:
|
||||
random.seed() # reset to time-based random source
|
||||
random.seed() # use time-based random source
|
||||
else:
|
||||
random.seed(seed)
|
||||
|
||||
seedname = "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.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
|
||||
erargs.race = race
|
||||
erargs.skip_playthrough = race
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
erargs.progression_balancing = {}
|
||||
erargs.create_diff = True
|
||||
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
|
||||
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["server_options"])
|
||||
|
||||
erargs.names = ",".join(erargs.name[i] for i in range(1, playercount + 1))
|
||||
del (erargs.name)
|
||||
return upload_to_db(target.name, sid, owner, race)
|
||||
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
thread = thread_pool.submit(task)
|
||||
|
||||
erargs.skip_progression_balancing = {player: not balanced for player, balanced in
|
||||
erargs.progression_balancing.items()}
|
||||
del (erargs.progression_balancing)
|
||||
ERmain(erargs, seed)
|
||||
|
||||
return upload_to_db(target.name, owner, sid, race)
|
||||
try:
|
||||
return thread.result(app.config["JOB_TIME"])
|
||||
except concurrent.futures.TimeoutError as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
gen = Generation.get(id=sid)
|
||||
if gen is not None:
|
||||
gen.state = STATE_ERROR
|
||||
meta = json.loads(gen.meta)
|
||||
meta["error"] = (
|
||||
"Allowed time for Generation exceeded, please consider generating locally instead. " +
|
||||
e.__class__.__name__ + ": " + str(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
commit()
|
||||
except BaseException as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
gen = Generation.get(id=sid)
|
||||
if gen is not None:
|
||||
gen.state = STATE_ERROR
|
||||
gen.meta = (e.__class__.__name__ + ": "+ str(e)).encode()
|
||||
meta = json.loads(gen.meta)
|
||||
meta["error"] = (e.__class__.__name__ + ": " + str(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
commit()
|
||||
raise
|
||||
|
||||
|
||||
@@ -116,47 +183,29 @@ 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:
|
||||
return "Generation not found."
|
||||
elif generation.state == STATE_ERROR:
|
||||
return render_template("seedError.html", seed_error=generation.meta.decode())
|
||||
return render_template("seedError.html", seed_error=generation.meta)
|
||||
return render_template("waitSeed.html", seed_id=seed_id)
|
||||
|
||||
|
||||
def upload_to_db(folder, owner, sid, race:bool):
|
||||
patches = 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])
|
||||
patches.add(Patch(data=open(file, "rb").read(),
|
||||
player_id=player_id, player_name = player_name))
|
||||
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, patches=patches, owner=owner,
|
||||
id=sid, meta={"tags": ["generated"]})
|
||||
else:
|
||||
seed = Seed(multidata=multidata, spoiler=spoiler, patches=patches, owner=owner,
|
||||
meta={"tags": ["generated"]})
|
||||
for patch in patches:
|
||||
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.")
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from flask import render_template
|
||||
from pony.orm import count
|
||||
|
||||
from WebHostLib import app, cache
|
||||
from .models import *
|
||||
from datetime import timedelta
|
||||
from .models import Room, Seed
|
||||
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
||||
|
||||
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 (file for file in os.listdir(input_dir) if not file.startswith(".")):
|
||||
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
|
||||
176
WebHostLib/misc.py
Normal file
176
WebHostLib/misc.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import datetime
|
||||
import os
|
||||
from typing import List, Dict, Union
|
||||
|
||||
import jinja2.exceptions
|
||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||
from pony.orm import count, commit, db_session
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import app, cache
|
||||
from .models import Seed, Room, Command, UUID, uuid4
|
||||
|
||||
|
||||
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
|
||||
if not session.get("_id", None):
|
||||
session["_id"] = uuid4() # uniquely identify each session without needing a login
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
||||
def page_not_found(err):
|
||||
return render_template('404.html'), 404
|
||||
|
||||
|
||||
# Start Playing Page
|
||||
@app.route('/start-playing')
|
||||
def start_playing():
|
||||
return render_template(f"startPlaying.html")
|
||||
|
||||
|
||||
@app.route('/weighted-settings')
|
||||
def weighted_settings():
|
||||
return render_template(f"weighted-settings.html")
|
||||
|
||||
|
||||
# Player settings pages
|
||||
@app.route('/games/<string:game>/player-settings')
|
||||
def player_settings(game):
|
||||
return render_template(f"player-settings.html", game=game, 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():
|
||||
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, theme=get_world_theme(game))
|
||||
|
||||
|
||||
@app.route('/tutorial/')
|
||||
def tutorial_landing():
|
||||
return render_template("tutorialLanding.html")
|
||||
|
||||
|
||||
@app.route('/faq/<string:lang>/')
|
||||
def faq(lang):
|
||||
return render_template("faq.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/glossary/<string:lang>/')
|
||||
def terms(lang):
|
||||
return render_template("glossary.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/seed/<suuid:seed>')
|
||||
def view_seed(seed: UUID):
|
||||
seed = Seed.get(id=seed)
|
||||
if not seed:
|
||||
abort(404)
|
||||
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
|
||||
|
||||
|
||||
@app.route('/new_room/<suuid:seed>')
|
||||
def new_room(seed: UUID):
|
||||
seed = Seed.get(id=seed)
|
||||
if not seed:
|
||||
abort(404)
|
||||
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
|
||||
commit()
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
|
||||
def _read_log(path: str):
|
||||
if os.path.exists(path):
|
||||
with open(path, encoding="utf-8-sig") as log:
|
||||
yield from log
|
||||
else:
|
||||
yield f"Logfile {path} does not exist. " \
|
||||
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
|
||||
|
||||
|
||||
@app.route('/log/<suuid:room>')
|
||||
def display_log(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if room.owner == session["_id"]:
|
||||
file_path = os.path.join("logs", str(room.id) + ".txt")
|
||||
if os.path.exists(file_path):
|
||||
return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
|
||||
return "Log File does not exist."
|
||||
|
||||
return "Access Denied", 403
|
||||
|
||||
|
||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||
def host_room(room: UUID):
|
||||
room: 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"]
|
||||
if cmd:
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
# indicate that the page should reload to get the assigned port
|
||||
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
|
||||
with db_session:
|
||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||
|
||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
|
||||
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
return send_from_directory(os.path.join(app.root_path, 'static/static'),
|
||||
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
||||
|
||||
|
||||
@app.route('/discord')
|
||||
def discord():
|
||||
return redirect("https://discord.gg/8Z65BR2")
|
||||
|
||||
|
||||
@app.route('/datapackage')
|
||||
@cache.cached()
|
||||
def get_datapackage():
|
||||
"""A pretty print version of /api/datapackage"""
|
||||
from worlds import network_data_package
|
||||
import json
|
||||
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
|
||||
|
||||
|
||||
@app.route('/index')
|
||||
@app.route('/sitemap')
|
||||
def get_sitemap():
|
||||
available_games: List[Dict[str, Union[str, bool]]] = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
has_settings: bool = isinstance(world.web.settings_page, bool) and world.web.settings_page
|
||||
available_games.append({ 'title': game, 'has_settings': has_settings })
|
||||
return render_template("siteMap.html", games=available_games)
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
from pony.orm import *
|
||||
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
|
||||
|
||||
db = Database()
|
||||
|
||||
@@ -9,25 +9,27 @@ STATE_STARTED = 1
|
||||
STATE_ERROR = -1
|
||||
|
||||
|
||||
class Patch(db.Entity):
|
||||
class Slot(db.Entity):
|
||||
id = PrimaryKey(int, auto=True)
|
||||
player_id = Required(int)
|
||||
player_name = Required(str, 16)
|
||||
data = Required(bytes, lazy=True)
|
||||
player_name = Required(str)
|
||||
data = Optional(bytes, lazy=True)
|
||||
seed = Optional('Seed')
|
||||
game = Required(str)
|
||||
|
||||
|
||||
class Room(db.Entity):
|
||||
id = PrimaryKey(UUID, default=uuid4)
|
||||
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow())
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
|
||||
owner = Required(UUID, index=True)
|
||||
commands = Set('Command')
|
||||
seed = Required('Seed', index=True)
|
||||
multisave = Optional(buffer, lazy=True)
|
||||
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
||||
timeout = Required(int, default=lambda: 6 * 60 * 60) # seconds since last activity to shutdown
|
||||
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
|
||||
tracker = Optional(UUID, index=True)
|
||||
# Port special value -1 means the server errored out. Another attempt can be made with a page refresh
|
||||
last_port = Optional(int, default=lambda: 0)
|
||||
|
||||
|
||||
@@ -36,10 +38,10 @@ class Seed(db.Entity):
|
||||
rooms = Set(Room)
|
||||
multidata = Required(bytes, lazy=True)
|
||||
owner = Required(UUID, index=True)
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow())
|
||||
patches = Set(Patch)
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
|
||||
slots = Set(Slot)
|
||||
spoiler = Optional(LongStr, lazy=True)
|
||||
meta = Required(Json, lazy=True, default=lambda: {}) # additional meta information/tags
|
||||
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
|
||||
|
||||
|
||||
class Command(db.Entity):
|
||||
@@ -51,6 +53,11 @@ class Command(db.Entity):
|
||||
class Generation(db.Entity):
|
||||
id = PrimaryKey(UUID, default=uuid4)
|
||||
owner = Required(UUID)
|
||||
options = Required(Json, lazy=True)
|
||||
meta = Required(Json, lazy=True)
|
||||
options = Required(buffer, lazy=True)
|
||||
meta = Required(LongStr, default=lambda: "{\"race\": false}")
|
||||
state = Required(int, default=0, index=True)
|
||||
|
||||
|
||||
class GameDataPackage(db.Entity):
|
||||
checksum = PrimaryKey(str)
|
||||
data = Required(bytes)
|
||||
|
||||
148
WebHostLib/options.py
Normal file
148
WebHostLib/options.py
Normal file
@@ -0,0 +1,148 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import typing
|
||||
|
||||
import yaml
|
||||
from jinja2 import Template
|
||||
|
||||
import Options
|
||||
from Utils import __version__, local_path
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||
"exclude_locations", "priority_locations"}
|
||||
|
||||
|
||||
def create():
|
||||
target_folder = local_path("WebHostLib", "static", "generated")
|
||||
yaml_folder = os.path.join(target_folder, "configs")
|
||||
|
||||
Options.generate_yaml_templates(yaml_folder)
|
||||
|
||||
def get_html_doc(option_type: type(Options.Option)) -> str:
|
||||
if not option_type.__doc__:
|
||||
return "Please document me!"
|
||||
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
|
||||
|
||||
weighted_settings = {
|
||||
"baseOptions": {
|
||||
"description": "Generated by https://archipelago.gg/",
|
||||
"name": "Player",
|
||||
"game": {},
|
||||
},
|
||||
"games": {},
|
||||
}
|
||||
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
|
||||
all_options: typing.Dict[str, Options.AssembleOptions] = {
|
||||
**Options.per_game_common_options,
|
||||
**world.option_definitions
|
||||
}
|
||||
|
||||
# Generate JSON files for player-settings pages
|
||||
player_settings = {
|
||||
"baseOptions": {
|
||||
"description": f"Generated by https://archipelago.gg/ for {game_name}",
|
||||
"game": game_name,
|
||||
"name": "Player",
|
||||
},
|
||||
}
|
||||
|
||||
game_options = {}
|
||||
for option_name, option in all_options.items():
|
||||
if option_name in handled_in_js:
|
||||
pass
|
||||
|
||||
elif issubclass(option, Options.Choice) or issubclass(option, Options.Toggle):
|
||||
game_options[option_name] = this_option = {
|
||||
"type": "select",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": None,
|
||||
"options": []
|
||||
}
|
||||
|
||||
for sub_option_id, sub_option_name in option.name_lookup.items():
|
||||
if sub_option_name != "random":
|
||||
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
|
||||
|
||||
if not this_option["defaultValue"]:
|
||||
this_option["defaultValue"] = "random"
|
||||
|
||||
elif issubclass(option, Options.Range):
|
||||
game_options[option_name] = {
|
||||
"type": "range",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": option.default if hasattr(
|
||||
option, "default") and option.default != "random" else option.range_start,
|
||||
"min": option.range_start,
|
||||
"max": option.range_end,
|
||||
}
|
||||
|
||||
if issubclass(option, Options.SpecialRange):
|
||||
game_options[option_name]["type"] = 'special_range'
|
||||
game_options[option_name]["value_names"] = {}
|
||||
for key, val in option.special_range_names.items():
|
||||
game_options[option_name]["value_names"][key] = val
|
||||
|
||||
elif issubclass(option, Options.ItemSet):
|
||||
game_options[option_name] = {
|
||||
"type": "items-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": list(option.default)
|
||||
}
|
||||
|
||||
elif issubclass(option, Options.LocationSet):
|
||||
game_options[option_name] = {
|
||||
"type": "locations-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": list(option.default)
|
||||
}
|
||||
|
||||
elif issubclass(option, Options.VerifyKeys) and not issubclass(option, Options.OptionDict):
|
||||
if option.valid_keys:
|
||||
game_options[option_name] = {
|
||||
"type": "custom-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"options": list(option.valid_keys),
|
||||
"defaultValue": list(option.default) if hasattr(option, "default") else []
|
||||
}
|
||||
|
||||
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:
|
||||
# Add the random option to Choice, TextChoice, and Toggle settings
|
||||
for option in game_options.values():
|
||||
if option["type"] == "select":
|
||||
option["options"].append({"name": "Random", "value": "random"})
|
||||
|
||||
if not option["defaultValue"]:
|
||||
option["defaultValue"] = "random"
|
||||
|
||||
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,7 +1,9 @@
|
||||
flask>=1.1.2
|
||||
pony>=0.7.14
|
||||
waitress>=2.0.0
|
||||
flask-caching>=1.10.1
|
||||
Flask-Autoversion>=0.2.0
|
||||
Flask-Compress>=1.9.0
|
||||
Flask-Limiter>=1.4
|
||||
flask>=2.2.3
|
||||
pony>=0.7.16; python_version <= '3.10'
|
||||
pony @ https://github.com/Berserker66/pony/releases/download/v0.7.16/pony-0.7.16-py3-none-any.whl#0.7.16 ; python_version >= '3.11'
|
||||
waitress>=2.1.2
|
||||
Flask-Caching>=2.0.2
|
||||
Flask-Compress>=1.13
|
||||
Flask-Limiter>=3.3.0
|
||||
bokeh>=3.1.1
|
||||
markupsafe>=2.1.3
|
||||
|
||||
@@ -4,6 +4,7 @@ window.addEventListener('load', () => {
|
||||
"ordering": true,
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
console.log(tables);
|
||||
});
|
||||
|
||||
40
WebHostLib/static/assets/baseHeader.js
Normal file
40
WebHostLib/static/assets/baseHeader.js
Normal file
@@ -0,0 +1,40 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Mobile menu handling
|
||||
const menuButton = document.getElementById('base-header-mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('base-header-mobile-menu');
|
||||
|
||||
menuButton.addEventListener('click', (evt) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
if (!mobileMenu.style.display || mobileMenu.style.display === 'none') {
|
||||
return mobileMenu.style.display = 'flex';
|
||||
}
|
||||
|
||||
mobileMenu.style.display = 'none';
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
mobileMenu.style.display = 'none';
|
||||
});
|
||||
|
||||
// Popover handling
|
||||
const popoverText = document.getElementById('base-header-popover-text');
|
||||
const popoverMenu = document.getElementById('base-header-popover-menu');
|
||||
|
||||
popoverText.addEventListener('click', (evt) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
if (!popoverMenu.style.display || popoverMenu.style.display === 'none') {
|
||||
return popoverMenu.style.display = 'flex';
|
||||
}
|
||||
|
||||
popoverMenu.style.display = 'none';
|
||||
});
|
||||
|
||||
document.body.addEventListener('click', () => {
|
||||
mobileMenu.style.display = 'none';
|
||||
popoverMenu.style.display = 'none';
|
||||
});
|
||||
});
|
||||
49
WebHostLib/static/assets/checksfinderTracker.js
Normal file
49
WebHostLib/static/assets/checksfinderTracker.js
Normal file
@@ -0,0 +1,49 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Reload tracker every 60 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();
|
||||
}, 60000)
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
});
|
||||
51
WebHostLib/static/assets/faq.js
Normal file
51
WebHostLib/static/assets/faq.js
Normal file
@@ -0,0 +1,51 @@
|
||||
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
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
});
|
||||
}).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>`;
|
||||
});
|
||||
});
|
||||
69
WebHostLib/static/assets/faq/faq_en.md
Normal file
69
WebHostLib/static/assets/faq/faq_en.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
## What is a randomizer?
|
||||
|
||||
A randomizer is a modification of a video game which reorganizes the items required to progress through the game. A
|
||||
normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a randomized
|
||||
game, you might first find item C, then A, then B.
|
||||
|
||||
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 a world. 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 release system. When a player releases 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/8Z65BR2). There are always people ready to answer
|
||||
any questions you might have.
|
||||
|
||||
## What are some common terms I should know?
|
||||
|
||||
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
|
||||
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
|
||||
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
|
||||
|
||||
## I want to add a game to the Archipelago randomizer. How do I do that?
|
||||
|
||||
The best way to get started is to take a look at our code on GitHub
|
||||
at [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
|
||||
|
||||
There you will find examples of games in the worlds folder
|
||||
at [/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
|
||||
|
||||
You may also find developer documentation in the docs folder
|
||||
at [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
||||
|
||||
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.
|
||||
94
WebHostLib/static/assets/faq/glossary_en.md
Normal file
94
WebHostLib/static/assets/faq/glossary_en.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Multiworld Glossary
|
||||
|
||||
There are a lot of common terms used when playing in different game randomizer communities and in multiworld as well.
|
||||
This document serves as a lookup for common terms that may be used by users in the community or in various other
|
||||
documentation.
|
||||
|
||||
## Item
|
||||
Items are what get shuffled around in your world or other worlds that you then receive. This could be a sword, a stat
|
||||
upgrade, a spell, or any other potential receivable for your game.
|
||||
|
||||
## Location
|
||||
Locations are where items are placed in your game. Whenever you interact with a location, you or another player will
|
||||
then receive an item. A location could be a chest, an enemy drop, a shop purchase, or any other interactable that can
|
||||
contain items in your game.
|
||||
|
||||
## Check
|
||||
A check is a common term for when you "check", or pick up, a location. In terms of Archipelago this is usually used for
|
||||
when a player goes to a location and sends its item, or "checks" the location. Players will often reference their now
|
||||
randomized locations as checks.
|
||||
|
||||
## Slot
|
||||
A slot is the player name and number assigned during generation. The number of slots is equal to the number of players,
|
||||
or "worlds", created. Each name must be unique as these are used to identify the slot user.
|
||||
|
||||
## World
|
||||
World in terms of Archipelago can mean multiple things and is used interchangeably in many situations.
|
||||
* During gameplay, a world is a single instance of a game, occupying one player "slot". However,
|
||||
Archipelago allows multiple players to connect to the same slot; then those players can share a world
|
||||
and complete it cooperatively. For games with native cooperative play, you can also play together and
|
||||
share a world that way, usually with only one player connected to the multiworld.
|
||||
* On the programming side, a world typically represents the package that integrates Archipelago with a
|
||||
particular game. For example this could be the entire `worlds/factorio` directory.
|
||||
|
||||
## RNG
|
||||
Acronym for "Random Number Generator." Archipelago uses its own custom Random object with a unique seed per generation,
|
||||
or, if running from source, a seed can be supplied and this seed will control all randomization during generation as all
|
||||
game worlds will have access to it.
|
||||
|
||||
## Seed
|
||||
A "seed" is a number used to initialize a pseudorandom number generator. Whenever you generate a new game on Archipelago
|
||||
this is a new "seed" as it has unique item placement, and you can create multiple "rooms" on the Archipelago site from a
|
||||
single seed. Using the same seed results in the random placement being the same.
|
||||
|
||||
## Room
|
||||
Whenever you generate a seed on the Archipelago website you will be put on a seed page that contains all the seed info
|
||||
with a link to the spoiler if one exists and will show how many unique rooms exist per seed. Each room has its own
|
||||
unique identifier that is separate from the seed. The room page is where you can find information to connect to the
|
||||
multiworld and download any patches if necessary. If you have a particularly fun or interesting seed, and you want to
|
||||
share it with somebody you can link them to this seed page, where they can generate a new room to play it! For seeds
|
||||
generated with race mode enabled, the seed page will only show rooms created by the unique user so the seed page is
|
||||
perfectly safe to share for racing purposes.
|
||||
|
||||
## Logic
|
||||
Base behavior of all seeds generated by Archipelago is they are expected to be completable based on the requirements of
|
||||
the settings. This is done by using "logic" in order to determine valid locations to place items while still being able
|
||||
to reach said location without this item. For the purposes of the randomizer a location is considered "in logic" if you
|
||||
can reach it with your current toolset of items or skills based on settings. Some players are able to obtain locations
|
||||
"out of logic" by performing various glitches or tricks that the settings may not account for and tend to mention this
|
||||
when sending out an item they obtained this way.
|
||||
|
||||
## Progression
|
||||
Certain items will allow access to more locations and are considered progression items as they "progress" the seed.
|
||||
|
||||
## Trash
|
||||
A term used for "filler" items that have no bearing on the generation and are either marginally useful for the player
|
||||
or useless. These items can be very useful depending on the player but are never very important and as such are usually
|
||||
termed trash.
|
||||
|
||||
## Burger King / BK Mode
|
||||
A term used in multiworlds when a player is unable to continue to progress and is awaiting an item. The term came to be
|
||||
after a player, allegedly, was unable to progress during a multiworld and went to Burger King while waiting to receive
|
||||
items from other players.
|
||||
|
||||
* "Logical BK" is when the player is unable to progress according to the settings of their game but may still be able to do
|
||||
things that would be "out of logic" by the generation.
|
||||
|
||||
* "Hard / full BK" is when the player is completely unable to progress even with tricks they may know and are unable to
|
||||
continue to play, aside from doing something like killing enemies for experience or money.
|
||||
|
||||
## Sphere
|
||||
Archipelago calculates the game playthrough by using a "sphere" system where it has a state for each player and checks
|
||||
to see what the players are able to reach with their current items. Any location that is reachable with the current
|
||||
state of items is a "sphere." For the purposes of Archipelago it starts playthrough calculation by distributing sphere 0
|
||||
items which are items that are either forced in the player's inventory by the game or placed in the `start_inventory` in
|
||||
their settings. Sphere 1 is then all accessible locations the players can reach with all the items they received from
|
||||
sphere 0, or their starting inventory. The playthrough continues in this fashion calculating a number of spheres until
|
||||
all players have completed their goal.
|
||||
|
||||
## Scouts / Scouting
|
||||
In some games there are locations that have visible items even if the item itself is unobtainable at the current time.
|
||||
Some games utilize a scouting feature where when the player "sees" the item it will give a free hint for the item in the
|
||||
client letting the players know what the exact item is, since if the item was for that game it would know but the item
|
||||
being foreign is a lot harder to represent visually.
|
||||
|
||||
51
WebHostLib/static/assets/gameInfo.js
Normal file
51
WebHostLib/static/assets/gameInfo.js
Normal file
@@ -0,0 +1,51 @@
|
||||
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/generated/docs/${gameInfo.getAttribute('data-game')}/` +
|
||||
`${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
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
gameInfo.innerHTML =
|
||||
`<h2>This page is out of logic!</h2>
|
||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
||||
});
|
||||
});
|
||||
51
WebHostLib/static/assets/glossary.js
Normal file
51
WebHostLib/static/assets/glossary.js
Normal file
@@ -0,0 +1,51 @@
|
||||
window.addEventListener('load', () => {
|
||||
const tutorialWrapper = document.getElementById('glossary-wrapper');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, the glossary page is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the glossary.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
|
||||
`glossary_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
// Populate page with HTML generated from markdown
|
||||
showdown.setOption('tables', true);
|
||||
showdown.setOption('strikethrough', true);
|
||||
showdown.setOption('literalMidWordUnderscores', true);
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
`<h2>This page is out of logic!</h2>
|
||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
||||
});
|
||||
});
|
||||
6
WebHostLib/static/assets/lttpMultiTracker.js
Normal file
6
WebHostLib/static/assets/lttpMultiTracker.js
Normal file
@@ -0,0 +1,6 @@
|
||||
window.addEventListener('load', () => {
|
||||
$(".table-wrapper").scrollsync({
|
||||
y_sync: true,
|
||||
x_sync: true
|
||||
});
|
||||
});
|
||||
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
398
WebHostLib/static/assets/player-settings.js
Normal file
398
WebHostLib/static/assets/player-settings.js
Normal file
@@ -0,0 +1,398 @@
|
||||
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;
|
||||
|
||||
fetchSettingData().then((results) => {
|
||||
let settingHash = localStorage.getItem(`${gameName}-hash`);
|
||||
if (!settingHash) {
|
||||
// If no hash data has been set before, set it now
|
||||
settingHash = md5(JSON.stringify(results));
|
||||
localStorage.setItem(`${gameName}-hash`, settingHash);
|
||||
localStorage.removeItem(gameName);
|
||||
}
|
||||
|
||||
if (settingHash !== md5(JSON.stringify(results))) {
|
||||
showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " +
|
||||
"them all to default.");
|
||||
document.getElementById('user-message').addEventListener('click', resetSettings);
|
||||
}
|
||||
|
||||
// Page setup
|
||||
createDefaultSettings(results);
|
||||
buildUI(results);
|
||||
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((e) => {
|
||||
console.error(e);
|
||||
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.textContent = `${settings[setting].displayName}: `;
|
||||
label.setAttribute('for', setting);
|
||||
|
||||
const questionSpan = document.createElement('span');
|
||||
questionSpan.classList.add('interactive');
|
||||
questionSpan.setAttribute('data-tooltip', settings[setting].description);
|
||||
questionSpan.innerText = '(?)';
|
||||
|
||||
label.appendChild(questionSpan);
|
||||
tdl.appendChild(label);
|
||||
tr.appendChild(tdl);
|
||||
|
||||
// td Right
|
||||
const tdr = document.createElement('td');
|
||||
let element = null;
|
||||
|
||||
const randomButton = document.createElement('button');
|
||||
|
||||
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.target));
|
||||
element.appendChild(select);
|
||||
|
||||
// Randomize button
|
||||
randomButton.innerText = '🎲';
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', setting);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
|
||||
if (currentSettings[gameName][setting] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
select.disabled = true;
|
||||
}
|
||||
|
||||
element.appendChild(randomButton);
|
||||
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.target);
|
||||
});
|
||||
element.appendChild(range);
|
||||
|
||||
let rangeVal = document.createElement('span');
|
||||
rangeVal.classList.add('range-value');
|
||||
rangeVal.setAttribute('id', `${setting}-value`);
|
||||
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
||||
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
||||
element.appendChild(rangeVal);
|
||||
|
||||
// Randomize button
|
||||
randomButton.innerText = '🎲';
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', setting);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
|
||||
if (currentSettings[gameName][setting] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
range.disabled = true;
|
||||
}
|
||||
|
||||
element.appendChild(randomButton);
|
||||
break;
|
||||
|
||||
case 'special_range':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('special-range-container');
|
||||
|
||||
// Build the select element
|
||||
let specialRangeSelect = document.createElement('select');
|
||||
specialRangeSelect.setAttribute('data-key', setting);
|
||||
Object.keys(settings[setting].value_names).forEach((presetName) => {
|
||||
let presetOption = document.createElement('option');
|
||||
presetOption.innerText = presetName;
|
||||
presetOption.value = settings[setting].value_names[presetName];
|
||||
const words = presetOption.innerText.split("_");
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
|
||||
}
|
||||
presetOption.innerText = words.join(" ");
|
||||
specialRangeSelect.appendChild(presetOption);
|
||||
});
|
||||
let customOption = document.createElement('option');
|
||||
customOption.innerText = 'Custom';
|
||||
customOption.value = 'custom';
|
||||
customOption.selected = true;
|
||||
specialRangeSelect.appendChild(customOption);
|
||||
if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
|
||||
specialRangeSelect.value = Number(currentSettings[gameName][setting]);
|
||||
}
|
||||
|
||||
// Build range element
|
||||
let specialRangeWrapper = document.createElement('div');
|
||||
specialRangeWrapper.classList.add('special-range-wrapper');
|
||||
let specialRange = document.createElement('input');
|
||||
specialRange.setAttribute('type', 'range');
|
||||
specialRange.setAttribute('data-key', setting);
|
||||
specialRange.setAttribute('min', settings[setting].min);
|
||||
specialRange.setAttribute('max', settings[setting].max);
|
||||
specialRange.value = currentSettings[gameName][setting];
|
||||
|
||||
// Build rage value element
|
||||
let specialRangeVal = document.createElement('span');
|
||||
specialRangeVal.classList.add('range-value');
|
||||
specialRangeVal.setAttribute('id', `${setting}-value`);
|
||||
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
||||
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
||||
|
||||
// Configure select event listener
|
||||
specialRangeSelect.addEventListener('change', (event) => {
|
||||
if (event.target.value === 'custom') { return; }
|
||||
|
||||
// Update range slider
|
||||
specialRange.value = event.target.value;
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event.target);
|
||||
});
|
||||
|
||||
// Configure range event handler
|
||||
specialRange.addEventListener('change', (event) => {
|
||||
// Update select element
|
||||
specialRangeSelect.value =
|
||||
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
||||
parseInt(event.target.value) : 'custom';
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event.target);
|
||||
});
|
||||
|
||||
element.appendChild(specialRangeSelect);
|
||||
specialRangeWrapper.appendChild(specialRange);
|
||||
specialRangeWrapper.appendChild(specialRangeVal);
|
||||
element.appendChild(specialRangeWrapper);
|
||||
|
||||
// Randomize button
|
||||
randomButton.innerText = '🎲';
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', setting);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(
|
||||
event, specialRange, specialRangeSelect)
|
||||
);
|
||||
if (currentSettings[gameName][setting] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
specialRange.disabled = true;
|
||||
specialRangeSelect.disabled = true;
|
||||
}
|
||||
|
||||
specialRangeWrapper.appendChild(randomButton);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
|
||||
return;
|
||||
}
|
||||
|
||||
tdr.appendChild(element);
|
||||
tr.appendChild(tdr);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
};
|
||||
|
||||
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
|
||||
const active = event.target.classList.contains('active');
|
||||
const randomButton = event.target;
|
||||
|
||||
if (active) {
|
||||
randomButton.classList.remove('active');
|
||||
inputElement.disabled = undefined;
|
||||
if (optionalSelectElement) {
|
||||
optionalSelectElement.disabled = undefined;
|
||||
}
|
||||
} else {
|
||||
randomButton.classList.add('active');
|
||||
inputElement.disabled = true;
|
||||
if (optionalSelectElement) {
|
||||
optionalSelectElement.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
updateGameSetting(randomButton);
|
||||
};
|
||||
|
||||
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 = (settingElement) => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
|
||||
if (settingElement.classList.contains('randomize-button')) {
|
||||
// If the event passed in is the randomize button, then we know what we must do.
|
||||
options[gameName][settingElement.getAttribute('data-key')] = 'random';
|
||||
} else {
|
||||
options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ?
|
||||
settingElement.value : parseInt(settingElement.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,
|
||||
spoiler: 3,
|
||||
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);
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
window.addEventListener('load', () => {
|
||||
Promise.all([fetchSettingData(), fetchSpriteData()]).then((results) => {
|
||||
// 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('playerSettings'));
|
||||
const nameInput = document.getElementById('player-name');
|
||||
nameInput.addEventListener('keyup', (event) => updateSetting(event));
|
||||
nameInput.value = playerSettings.name;
|
||||
|
||||
// Sprite options
|
||||
const spriteData = JSON.parse(results[1]);
|
||||
const spriteSelect = document.getElementById('sprite');
|
||||
spriteData.sprites.forEach((sprite) => {
|
||||
if (sprite.name.trim().length === 0) { return; }
|
||||
const option = document.createElement('option');
|
||||
option.setAttribute('value', sprite.name.trim());
|
||||
if (playerSettings.rom.sprite === sprite.name.trim()) { option.selected = true; }
|
||||
option.innerText = sprite.name;
|
||||
spriteSelect.appendChild(option);
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
})
|
||||
});
|
||||
|
||||
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/static/playerSettings.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
const createDefaultSettings = (settingData) => {
|
||||
if (!localStorage.getItem('playerSettings')) {
|
||||
const newSettings = {};
|
||||
for (let roSetting of Object.keys(settingData.readOnly)){
|
||||
newSettings[roSetting] = settingData.readOnly[roSetting];
|
||||
}
|
||||
for (let generalOption of Object.keys(settingData.generalOptions)){
|
||||
newSettings[generalOption] = settingData.generalOptions[generalOption];
|
||||
}
|
||||
for (let gameOption of Object.keys(settingData.gameOptions)){
|
||||
newSettings[gameOption] = settingData.gameOptions[gameOption].defaultValue;
|
||||
}
|
||||
newSettings.rom = {};
|
||||
for (let romOption of Object.keys(settingData.romOptions)){
|
||||
newSettings.rom[romOption] = settingData.romOptions[romOption].defaultValue;
|
||||
}
|
||||
localStorage.setItem('playerSettings', 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));
|
||||
|
||||
// ROM Options
|
||||
const leftRomOpts = {};
|
||||
const rightRomOpts = {};
|
||||
Object.keys(settingData.romOptions).forEach((key, index) => {
|
||||
if (index < Object.keys(settingData.romOptions).length / 2) { leftRomOpts[key] = settingData.romOptions[key]; }
|
||||
else { rightRomOpts[key] = settingData.romOptions[key]; }
|
||||
});
|
||||
document.getElementById('rom-options-left').appendChild(buildOptionsTable(leftRomOpts, true));
|
||||
document.getElementById('rom-options-right').appendChild(buildOptionsTable(rightRomOpts, true));
|
||||
};
|
||||
|
||||
const buildOptionsTable = (settings, romOpts = false) => {
|
||||
const currentSettings = JSON.parse(localStorage.getItem('playerSettings'));
|
||||
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].friendlyName}:`;
|
||||
tdl.appendChild(label);
|
||||
tr.appendChild(tdl);
|
||||
|
||||
// td Right
|
||||
const tdr = document.createElement('td');
|
||||
const 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[setting]) && (parseInt(opt.value, 10) === parseInt(currentSettings[setting]))) ||
|
||||
(opt.value === currentSettings[setting])) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.addEventListener('change', (event) => updateSetting(event));
|
||||
tdr.appendChild(select);
|
||||
tr.appendChild(tdr);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
};
|
||||
|
||||
const updateSetting = (event) => {
|
||||
const options = JSON.parse(localStorage.getItem('playerSettings'));
|
||||
if (event.target.getAttribute('data-romOpt')) {
|
||||
options.rom[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
} else {
|
||||
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
}
|
||||
localStorage.setItem('playerSettings', JSON.stringify(options));
|
||||
};
|
||||
|
||||
const exportSettings = () => {
|
||||
const settings = JSON.parse(localStorage.getItem('playerSettings'));
|
||||
if (!settings.name || settings.name.trim().length === 0) { settings.name = "noname"; }
|
||||
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) => {
|
||||
axios.post('/api/generate', {
|
||||
weights: { player: localStorage.getItem('playerSettings') },
|
||||
presetData: { player: localStorage.getItem('playerSettings') },
|
||||
playerCount: 1,
|
||||
race: raceMode ? '1' : '0',
|
||||
}).then((response) => {
|
||||
window.location.href = response.data.url;
|
||||
});
|
||||
};
|
||||
|
||||
const fetchSpriteData = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status !== 200) {
|
||||
reject('Unable to fetch sprite data.');
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/static/spriteData.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
49
WebHostLib/static/assets/sc2wolTracker.js
Normal file
49
WebHostLib/static/assets/sc2wolTracker.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/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;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
const adjustTableHeight = () => {
|
||||
const tablesContainer = document.getElementById('tables-container');
|
||||
const upperDistance = tablesContainer.getBoundingClientRect().top;
|
||||
|
||||
const containerHeight = window.innerHeight - upperDistance;
|
||||
tablesContainer.style.maxHeight = `calc(${containerHeight}px - 1rem)`;
|
||||
|
||||
const tableWrappers = document.getElementsByClassName('table-wrapper');
|
||||
for(let i=0; i < tableWrappers.length; i++){
|
||||
const maxHeight = (window.innerHeight - upperDistance) / 2;
|
||||
tableWrappers[i].style.maxHeight = `calc(${maxHeight}px - 1rem)`;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const tables = $(".table").DataTable({
|
||||
paging: false,
|
||||
info: false,
|
||||
dom: "t",
|
||||
|
||||
// DO NOT use the scrollX or scrollY options. They cause DataTables to split the thead from
|
||||
// the tbody and render two separate tables.
|
||||
});
|
||||
|
||||
document.getElementById('search').addEventListener('keyup', (event) => {
|
||||
tables.search(event.target.value);
|
||||
console.info(tables.search());
|
||||
tables.draw();
|
||||
});
|
||||
|
||||
const update = () => {
|
||||
const target = $("<div></div>");
|
||||
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||
target.load("/tracker/" + tracker, function (response, status) {
|
||||
if (status === "success") {
|
||||
target.find(".table").each(function (i, new_table) {
|
||||
const new_trs = $(new_table).find("tbody>tr");
|
||||
const old_table = tables.eq(i);
|
||||
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
||||
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
||||
old_table.clear();
|
||||
old_table.rows.add(new_trs).draw();
|
||||
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
||||
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
||||
});
|
||||
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
|
||||
} else {
|
||||
console.log("Failed to connect to Server, in order to update Table Data.");
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setInterval(update, 30000);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
adjustTableHeight();
|
||||
tables.draw();
|
||||
});
|
||||
|
||||
$(".table-wrapper").scrollsync({
|
||||
y_sync: true,
|
||||
x_sync: true
|
||||
});
|
||||
|
||||
adjustTableHeight();
|
||||
});
|
||||
141
WebHostLib/static/assets/trackerCommon.js
Normal file
141
WebHostLib/static/assets/trackerCommon.js
Normal file
@@ -0,0 +1,141 @@
|
||||
const adjustTableHeight = () => {
|
||||
const tablesContainer = document.getElementById('tables-container');
|
||||
if (!tablesContainer)
|
||||
return;
|
||||
const upperDistance = tablesContainer.getBoundingClientRect().top;
|
||||
|
||||
const containerHeight = window.innerHeight - upperDistance;
|
||||
tablesContainer.style.maxHeight = `calc(${containerHeight}px - 1rem)`;
|
||||
|
||||
const tableWrappers = document.getElementsByClassName('table-wrapper');
|
||||
for(let i=0; i < tableWrappers.length; i++){
|
||||
const maxHeight = (window.innerHeight - upperDistance) / 2;
|
||||
tableWrappers[i].style.maxHeight = `calc(${maxHeight}px - 1rem)`;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const tables = $(".table").DataTable({
|
||||
paging: false,
|
||||
info: false,
|
||||
dom: "t",
|
||||
stateSave: true,
|
||||
stateSaveCallback: function(settings, data) {
|
||||
delete data.search;
|
||||
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
|
||||
},
|
||||
stateLoadCallback: function(settings) {
|
||||
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
|
||||
},
|
||||
columnDefs: [
|
||||
{
|
||||
targets: 'hours',
|
||||
render: function (data, type, row) {
|
||||
if (type === "sort" || type === 'type') {
|
||||
if (data === "None")
|
||||
return -1;
|
||||
|
||||
return parseInt(data);
|
||||
}
|
||||
if (data === "None")
|
||||
return data;
|
||||
|
||||
let hours = Math.floor(data / 3600);
|
||||
let minutes = Math.floor((data - (hours * 3600)) / 60);
|
||||
|
||||
if (minutes < 10) {minutes = "0"+minutes;}
|
||||
return hours+':'+minutes;
|
||||
}
|
||||
},
|
||||
{
|
||||
targets: 'number',
|
||||
render: function (data, type, row) {
|
||||
if (type === "sort" || type === 'type') {
|
||||
return parseFloat(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
},
|
||||
{
|
||||
targets: 'fraction',
|
||||
render: function (data, type, row) {
|
||||
let splitted = data.split("/", 1);
|
||||
let current = splitted[0]
|
||||
if (type === "sort" || type === 'type') {
|
||||
return parseInt(current);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
// DO NOT use the scrollX or scrollY options. They cause DataTables to split the thead from
|
||||
// the tbody and render two separate tables.
|
||||
});
|
||||
|
||||
const searchBox = document.getElementById("search");
|
||||
searchBox.value = tables.search();
|
||||
searchBox.focus();
|
||||
searchBox.select();
|
||||
const doSearch = () => {
|
||||
tables.search(searchBox.value);
|
||||
tables.draw();
|
||||
};
|
||||
searchBox.addEventListener("keyup", doSearch);
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (!event.ctrlKey && !event.altKey && event.key.length === 1 && document.activeElement !== searchBox) {
|
||||
searchBox.focus();
|
||||
searchBox.select();
|
||||
}
|
||||
if (!event.ctrlKey && !event.altKey && !event.shiftKey && event.key === "Escape") {
|
||||
if (searchBox.value !== "") {
|
||||
searchBox.value = "";
|
||||
doSearch();
|
||||
}
|
||||
searchBox.blur();
|
||||
if (!document.getElementById("tables-container"))
|
||||
window.scroll(0, 0);
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||
const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
|
||||
|
||||
function getSleepTimeSeconds(){
|
||||
// -40 % 60 is -40, which is absolutely wrong and should burn
|
||||
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
|
||||
return sleepSeconds || 60;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const target = $("<div></div>");
|
||||
console.log("Updating Tracker...");
|
||||
target.load(location.href, function (response, status) {
|
||||
if (status === "success") {
|
||||
target.find(".table").each(function (i, new_table) {
|
||||
const new_trs = $(new_table).find("tbody>tr");
|
||||
const old_table = tables.eq(i);
|
||||
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
||||
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
||||
old_table.clear();
|
||||
old_table.rows.add(new_trs).draw();
|
||||
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
||||
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
||||
});
|
||||
$("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
|
||||
} else {
|
||||
console.log("Failed to connect to Server, in order to update Table Data.");
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||
}
|
||||
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
adjustTableHeight();
|
||||
tables.draw();
|
||||
});
|
||||
|
||||
adjustTableHeight();
|
||||
});
|
||||
@@ -14,34 +14,41 @@ window.addEventListener('load', () => {
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/tutorial/` +
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
|
||||
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
|
||||
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
||||
ajax.send();
|
||||
}).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();
|
||||
|
||||
const title = document.querySelector('h1')
|
||||
if (title) {
|
||||
document.title = title.textContent;
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
# Minecraft Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
### Server Host
|
||||
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.16.5.html)
|
||||
- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
|
||||
|
||||
### Players
|
||||
- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition)
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
### Dedicated Server Setup
|
||||
Only one person has to do this setup and host a dedicated server for everyone else playing to connect to.
|
||||
1. Download the 1.16.5 **Minecraft Forge** installer from the link above, making sure to download the most recent recommended version.
|
||||
|
||||
2. Run the `forge-1.16.5-xx.x.x-installer.jar` file and choose **install server**.
|
||||
- On this page you will also choose where to install the server to remember this directory it's important in the next step.
|
||||
|
||||
3. Navigate to where you installed the server and open `forge-1.16.5-xx.x.x.jar`
|
||||
- Upon first launch of the server it will close and ask you to accept Minecraft's EULA. There will be a new file called `eula.txt` that contains a link to Minecraft's EULA, and a line that you need to change to `eula=true` to accept Minecraft's EULA.
|
||||
- This will create the appropriate directories for you to place the files in the following step.
|
||||
|
||||
4. Place the `aprandomizer-x.x.x.jar` from the link above file into the `mods` folder of the above installation of your forge server.
|
||||
- Once again run the server, it will load up and generate the required directory `APData` for when you are ready to play a game!
|
||||
|
||||
### Basic Player Setup
|
||||
- Purchase and install Minecraft from the above link.
|
||||
|
||||
**You're Done**.
|
||||
|
||||
Players only need to have a Vanilla unmodified version of Minecraft to play!
|
||||
|
||||
### Advanced Player Setup
|
||||
***This is not required to play a randomized minecraft game.***
|
||||
however this recommended as it helps make the experience more enjoyable.
|
||||
|
||||
#### Recomended Mods
|
||||
- [JourneyMap](https://www.curseforge.com/minecraft/mc-mods/journeymap) (Minimap)
|
||||
|
||||
|
||||
1. Install and run Minecraft from the link above at least once.
|
||||
2. Run the `forge-1.16.5-xx.x.x-installer.jar` file and choose **install client**.
|
||||
- Start Minecraft forge at least once to create the directories needed for the next steps.
|
||||
3. Navigate to your minecraft install directory and place desired mods `.jar` file the in the `mods` directory.
|
||||
- The default install directories are as follows.
|
||||
- Windows `%APPDATA%\.minecraft\mods`
|
||||
- macOS `~/Library/Application Support/minecraft/mods`
|
||||
- Linux `~/.minecraft/mods`
|
||||
|
||||
## Configuring your YAML file
|
||||
|
||||
### What is a YAML file and why do I need one?
|
||||
Your YAML file contains a set of configuration options which provide the generator with information about how
|
||||
it should generate your game. Each player of a multiworld will provide their own YAML file. This setup allows
|
||||
each player to enjoy an experience customized for their taste, and different players in the same multiworld
|
||||
can all have different options.
|
||||
|
||||
### Where do I get a YAML file?
|
||||
A basic minecraft yaml will look like this.
|
||||
```yaml
|
||||
description: Template Name
|
||||
# Your name in-game. Spaces will be replaced with underscores and
|
||||
# there is a 16 character limit
|
||||
name: YourName
|
||||
game: Minecraft
|
||||
|
||||
# Shared Options supported by all games:
|
||||
accessibility: locations
|
||||
progression_balancing: off
|
||||
# Minecraft Specific Options
|
||||
|
||||
# Number of advancements required (out of 92 total) to spawn the
|
||||
# Ender Dragon and complete the game.
|
||||
advancement_goal:
|
||||
few: 0 #30
|
||||
normal: 1 #50
|
||||
many: 0 #70
|
||||
|
||||
# Modifies the level of items logically required for exploring
|
||||
# dangerous areas and fighting bosses.
|
||||
combat_difficulty:
|
||||
easy: 0
|
||||
normal: 1
|
||||
hard: 0
|
||||
|
||||
# Junk-fills certain RNG-reliant or tedious advancements with XP rewards.
|
||||
include_hard_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
|
||||
# Junk-fills extremely difficult advancements;
|
||||
# this is only How Did We Get Here? and Adventuring Time.
|
||||
include_insane_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
|
||||
# Some advancements require defeating the Ender Dragon;
|
||||
# this will junk-fill them so you won't have to finish to send some items.
|
||||
include_postgame_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
|
||||
#enables shuffling of villages, outposts, fortresses, bastions, and end cities.
|
||||
shuffle_structures:
|
||||
on: 1
|
||||
off: 0
|
||||
```
|
||||
|
||||
For more detail on what each setting does check the default `PlayerSettings.yaml` that comes with the Archipelago install.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
### Obtain your Minecraft data file
|
||||
**Only one yaml file needs to be submitted per minecraft world regardless of how many players play on it.**
|
||||
|
||||
When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that
|
||||
is done, the host will provide you with either a link to download your data file, or with a zip file containing
|
||||
everyone's data files. Your data file should have a `.apmc` extension.
|
||||
|
||||
Put your data file in your forge servers `APData` folder. Make sure to remove any previous data file that was in there
|
||||
previously.
|
||||
|
||||
### Connect to the MultiServer
|
||||
After having placed your data file in the `APData` folder, start the Forge server and make sure you have OP
|
||||
status by typing `/op YourMinecraftUsername` in the forge server console then connecting in your Minecraft client.
|
||||
|
||||
Once in game type `/connect <AP-Address> (<Password>)` where `<AP-Address>` is the address of the
|
||||
Archipelago server. `(<Password>)`
|
||||
is only required if the Archipleago server you are using has a password set.
|
||||
|
||||
### Play the game
|
||||
When the console tells you that you have joined the room, you're ready to begin playing. Congratulations
|
||||
on successfully joining a multiworld game! At this point any additional minecraft players may connect to your
|
||||
forge server.
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
# Guia instalación de Minecraft Randomizer
|
||||
|
||||
## Software Requerido
|
||||
|
||||
### Servidor
|
||||
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.16.5.html)
|
||||
- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
|
||||
|
||||
### Jugadores
|
||||
- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition)
|
||||
|
||||
## Procedimiento de instalación
|
||||
|
||||
### Instalación de servidor dedicado
|
||||
Solo una persona ha de realizar este proceso y hospedar un servidor dedicado para que los demas jueguen conectandose a él.
|
||||
1. Descarga el instalador de **Minecraft Forge** 1.16.15 desde el enlace proporcionado, siempre asegurandose de bajar la version mas reciente.
|
||||
|
||||
2. Ejecuta el fichero `forge-1.16.5-xx.x.x-installer.jar` y elije **install server**.
|
||||
- En esta pagina elegiras ademas donde instalar el servidor, importante recordar esta localización en el siguiente paso.
|
||||
|
||||
3. Navega al directorio donde hayas instalado el servidor y abre `forge-1.16.5-xx.x.x.jar`
|
||||
- La primera vez que lances el servidor se cerrara (o no aparecerá nada en absoluto), debería haber un fichero nuevo en el directorio llamado `eula.txt`, el cual que contiene un enlace al EULA de minecraft, cambia la linea a `eula=true` para aceptar el EULA y poder utilizar el software de servidor.
|
||||
- Esto creara la estructura de directorios apropiada para el siguiente paso
|
||||
|
||||
4. Coloca el fichero `aprandomizer-x.x.x.jar` del segundo enlace en el directorio `mods`
|
||||
- Cuando se ejecute el servidor de nuevo, generara el directorio `APData` que se necesitara para jugar
|
||||
|
||||
### Instalación basica para jugadores
|
||||
- Compra e instala Minecraft a traves del tercer enlace.
|
||||
**Y listo!**.
|
||||
Los jugadores solo necesitan una version no modificada de Minecraft para jugar!
|
||||
|
||||
### Instalación avanzada para jugadores
|
||||
***Esto no es requerido para jugar a minecraft randomizado.***
|
||||
Sin embargo lo recomendamos porque hace la experiencia mas llevadera.
|
||||
|
||||
#### Recomended Mods
|
||||
- [JourneyMap](https://www.curseforge.com/minecraft/mc-mods/journeymap) (Minimap)
|
||||
|
||||
|
||||
1. Instala y ejecuta Minecraft al menos una vez.
|
||||
2. Ejecuta el fichero `forge-1.16.5-xx.x.x-installer.jar` y elige **install client**.
|
||||
- Ejecuta Minecraft forge al menos una vez para generar los directorios necesarios para el siguiente paso.
|
||||
3. Navega a la carpeta de instalación de Minecraft y colocal los mods que quieras en el directorio `mods`
|
||||
- Los directorios por defecto de instalación son:
|
||||
- Windows `%APPDATA%\.minecraft\mods`
|
||||
- macOS `~/Library/Application Support/minecraft/mods`
|
||||
- Linux `~/.minecraft/mods`
|
||||
|
||||
## Configura tu fichero YAML
|
||||
|
||||
### Que es un fichero YAML y potque necesito uno?
|
||||
Tu fichero YAML contiene un numero de opciones que proveen al generador con informacion sobre como debe generar tu juego.
|
||||
Cada jugador de un multiworld entregara u propio fichero YAML.
|
||||
Esto permite que cada jugador disfrute de una experiencia personalizada a su gusto y diferentes jugadores dentro del mismo multiworld
|
||||
pueden tener diferentes opciones
|
||||
|
||||
### Where do I get a YAML file?
|
||||
Un fichero basico yaml para minecraft tendra este aspecto.
|
||||
```yaml
|
||||
# Usado para describir tu yaml. Util si tienes multiples ficheros
|
||||
description: Template Name
|
||||
# Tu nombre en el juego. Los espacios son reemplazados por guiones bajos, limitado a 16 caracteres
|
||||
name: YourName
|
||||
game: Minecraft
|
||||
accessibility: locations
|
||||
# Recomendado no activar esto ya que el pool de objetos de Minecraft es bastante escueto, ademas hay muchas maneras alternativas de obtener los objetivos de Minecraft.
|
||||
progression_balancing: off
|
||||
# Cuantos avances se necesitan para hacer aparecer el Ender Dragon y acabar el juego. few = 30, normal = 50 , many = 70
|
||||
advancement_goal:
|
||||
few: 0
|
||||
normal: 1
|
||||
many: 0
|
||||
# Modifica el nivel de objetos lógicamente requeridos para explorar areas peligrosas y pelear contra jefes.
|
||||
combat_difficulty:
|
||||
easy: 0
|
||||
normal: 1
|
||||
hard: 0
|
||||
# Avances que sean tediosos o basados en suerte tendran simplemente experiencia o cosas no necesarias
|
||||
include_hard_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
# Los avances extremadamente difíciles no seran requeridos; esto afecta a How Did We Get Here? y Adventuring Time.
|
||||
include_insane_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
# Los avances posteriores a Ender Dragon no tendrán objetos necesarios para que otros jugadores en el caso de un MW acaben su partida.
|
||||
include_postgame_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
# Actualmente desactivado; permite la mezcla de pueblos, puestos, fortalezas, bastiones y cuidades.
|
||||
shuffle_structures:
|
||||
on: 0
|
||||
off: 1
|
||||
```
|
||||
|
||||
|
||||
## Unirse a un juego MultiWorld
|
||||
|
||||
### Obten tu ficheros de datos Minecraft
|
||||
**Solo un fichero yaml es necesario por mundo minecraft, sin importar el numero de jugadores que jueguen en el.**
|
||||
|
||||
Cuando te unes a un juego multiworld, se te pedirá que entregues tu fichero YAML a quien sea que hospede el juego multiworld (no confundir con hospedar el mundo minecraft).
|
||||
Una vez la generación acabe, el anfitrión te dará un enlace a tu fichero de datos o un zip con los ficheros de todos.
|
||||
Tu fichero de datos tiene una extensión `.apmc`.
|
||||
|
||||
Pon tu fichero de datos en el directorio `APData` de tu forge server. Asegurate de eliminar los que hubiera anteriormente
|
||||
|
||||
|
||||
### Conectar al multiserver
|
||||
Despues de poner tu fichero en el directorio `APData`, arranca el Forge server y asegurate que tienes el estado OP
|
||||
tecleando `/op TuUsuarioMinecraft` en la consola del servidor y entonces conectate con tu cliente Minecraft.
|
||||
|
||||
Una vez en juego introduce `/connect <AP-Address> (<Password>)` donde `<AP-Address>` es la dirección del servidor
|
||||
Archipelago. `(<Password>)`
|
||||
solo se necesita si el servidor Archipleago tiene un password activo.
|
||||
|
||||
### Jugar al juego
|
||||
Cuando la consola te diga que te has unido a la sala, estas lista/o para empezar a jugar. Felicidades
|
||||
por unirte exitosamente a un juego multiworld! Llegados a este punto cualquier jugador adicional puede conectarse a tu servidor forge.
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
[
|
||||
{
|
||||
"gameTitle": "The Legend of Zelda: A Link to the Past",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago ALttP software on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda3/multiworld_en.md",
|
||||
"link": "zelda3/multiworld/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Deutsch",
|
||||
"filename": "zelda3/multiworld_de.md",
|
||||
"link": "zelda3/multiworld/de",
|
||||
"authors": [
|
||||
"Fischfilet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Español",
|
||||
"filename": "zelda3/multiworld_es.md",
|
||||
"link": "zelda3/multiworld/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Français",
|
||||
"filename": "zelda3/multiworld_fr.md",
|
||||
"link": "zelda3/multiworld/fr",
|
||||
"authors": [
|
||||
"Coxla"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MSU-1 Setup Tutorial",
|
||||
"description": "A guide to setting up MSU-1, which allows for custom in-game music.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda3/msu1_en.md",
|
||||
"link": "zelda3/msu1/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Español",
|
||||
"filename": "zelda3/msu1_es.md",
|
||||
"link": "zelda3/msu1/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Français",
|
||||
"filename": "msu1_fr.md",
|
||||
"link": "zelda3/msu1/fr",
|
||||
"authors": [
|
||||
"Coxla"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Plando Tutorial",
|
||||
"description": "A guide to creating Multiworld Plandos",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda3/plando_en.md",
|
||||
"link": "zelda3/plando/en",
|
||||
"authors": [
|
||||
"Berserker"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Minecraft",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago Minecraft software on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "minecraft/minecraft_en.md",
|
||||
"link": "minecraft/minecraft/en",
|
||||
"authors": [
|
||||
"Kono Tyran"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Spanish",
|
||||
"filename": "minecraft/minecraft_es.md",
|
||||
"link": "minecraft/minecraft/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Swedish",
|
||||
"filename": "minecraft/minecraft_sv.md",
|
||||
"link": "minecraft/minecraft/sv",
|
||||
"authors": [
|
||||
"Albinum"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,180 +0,0 @@
|
||||
# A Link to the Past Randomizer Setup Guide
|
||||
|
||||
<div id="tutorial-video-container">
|
||||
<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/mJKEHaiyR_Y" frameborder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen>
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
## Required Software
|
||||
- [MultiWorld Utilities](https://github.com/Berserker66/MultiWorld-Utilities/releases)
|
||||
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Included in the above Utilities)
|
||||
- Hardware or software capable of loading and playing SNES ROM files
|
||||
- An emulator capable of running Lua scripts
|
||||
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html))
|
||||
- An SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
|
||||
- Your Japanese v1.0 ROM file, probably named `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
### Windows Setup
|
||||
1. Download and install the MultiWorld Utilities from the link above, making sure to install the most recent version.
|
||||
**The file is located in the assets section at the bottom of the version information**. If you intend to play normal
|
||||
multiworld games, you want `Setup.BerserkerMultiWorld.exe`
|
||||
- If you intend to play the doors variant of multiworld, you will want to download the alternate doors file.
|
||||
- During the installation process, you will be asked to browse for your Japanese 1.0 ROM file. If you have
|
||||
installed this software before and are simply upgrading now, you will not be prompted to locate your
|
||||
ROM file a second time.
|
||||
- You may also be prompted to install Microsoft Visual C++. If you already have this software on your computer
|
||||
(possibly because a Steam game installed it already), the installer will not prompt you to install it again.
|
||||
|
||||
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.
|
||||
|
||||
### Macintosh Setup
|
||||
- We need volunteers to help fill this section! Please contact **Farrak Kilhn** on Discord if you want to help.
|
||||
|
||||
## Configuring your YAML file
|
||||
|
||||
### What is a YAML file and why do I need one?
|
||||
Your YAML file contains a set of configuration options which provide the generator with information about how
|
||||
it should generate your game. Each player of a multiworld will provide their own YAML file. This setup allows
|
||||
each player to enjoy an experience customized for their taste, and different players in the same multiworld
|
||||
can all have different options.
|
||||
|
||||
### Where do I get a YAML file?
|
||||
The [Generate Game](/player-settings) page on the website allows you to configure your personal settings and
|
||||
export a YAML file from them.
|
||||
|
||||
### Advanced YAML configuration
|
||||
A more advanced version of the YAML file can be created using the [Weighted Settings](/weighted-settings) page,
|
||||
which allows you to configure up to three presets. The Weighted Settings page has many options which are
|
||||
primarily represented with sliders. This allows you to choose how likely certain options are to occur relative
|
||||
to other options within a category.
|
||||
|
||||
For example, imagine the generator creates a bucket labeled "Map Shuffle", and places folded pieces of paper
|
||||
into the bucket for each sub-option. Also imagine your chosen value for "On" is 20, and your value for "Off" is 40.
|
||||
|
||||
In this example, sixty pieces of paper are put into the bucket. Twenty for "On" and forty for "Off". When the
|
||||
generator is deciding whether or not to turn on map shuffle for your game, it reaches into this bucket and pulls
|
||||
out a piece of paper at random. In this example, you are much more likely to have map shuffle turned off.
|
||||
|
||||
If you never want an option to be chosen, simply set its value to zero. Remember that each setting must have at
|
||||
lease one option set to a number greater than zero.
|
||||
|
||||
### Verifying your YAML file
|
||||
If you would like to validate your YAML 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 [Generate Game](/player-settings), configure your options, and click the "Generate Game" button.
|
||||
2. You will be presented with a "Seed Info" page, where you can download your patch file.
|
||||
3. Double-click on your patch file, and the emulator should launch with your game automatically. As the
|
||||
Client is unnecessary for single player games, you may close it and the WebUI.
|
||||
|
||||
## 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 YAML 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 `.bmbp` 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 file in the same place as your patch file.
|
||||
|
||||
### Connect to the client
|
||||
|
||||
#### With an emulator
|
||||
When the client launched automatically, QUsb2Snes 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. Browse to the location you extracted snes9x Multitroid to, enter the `lua` folder, and choose `multibridge.lua`
|
||||
6. Observe a name has been assigned to you, and that the client shows "SNES Device: Connected", with that same
|
||||
name in the upper left corner.
|
||||
|
||||
##### BizHawk
|
||||
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following
|
||||
these menu options:
|
||||
`Config --> Cores --> SNES --> BSNES`
|
||||
Once you have changed the loaded core, you must restart BizHawk.
|
||||
2. Load your ROM file if it hasn't already been loaded.
|
||||
3. Click on the Tools menu and click on **Lua Console**
|
||||
4. Click the button to open a new Lua script.
|
||||
5. Browse to your MultiWorld Utilities installation directory, and into the following directories:
|
||||
`QUsb2Snes/Qusb2Snes/LuaBridge`
|
||||
6. Select `luabridge.lua` and click Open.
|
||||
7. Observe a name has been assigned to you, and that the client shows "SNES Device: Connected", with that same
|
||||
name in the upper left corner.
|
||||
|
||||
#### 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.
|
||||
3. Observe the client window now shows "SNES Device: Connected", and lists the name of your device.
|
||||
|
||||
### Connect to the MultiServer
|
||||
The patch file which launched your client should have automatically connected you to the MultiServer.
|
||||
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". If the client does not connect after a few moments, you may need to refresh the page.
|
||||
|
||||
### Play the game
|
||||
When the client shows both SNES Device and Server as connected, you're ready to begin playing. Congratulations
|
||||
on successfully joining a multiworld game!
|
||||
|
||||
## Hosting a MultiWorld game
|
||||
The recommended way to host a game is to use the hosting service provided on
|
||||
[the website](https://berserkermulti.world/generate). The process is relatively simple:
|
||||
|
||||
1. Collect YAML files from your players.
|
||||
2. Create a zip file containing your players' YAML 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.
|
||||
**Note:** The patch files provided on this page will allow players to automatically connect to the server,
|
||||
while the patch files on the "Seed Info" page will not.
|
||||
7. Note that a link to a MultiWorld Tracker is at the top of the room page. You should also provide this link
|
||||
to your players, so they can watch the progress of the game. Any observers may also be given the link to
|
||||
this page.
|
||||
8. Once all players have joined, you may begin playing.
|
||||
|
||||
## Auto-Tracking
|
||||
If you would like to use auto-tracking for your game, several pieces of software provide this functionality.
|
||||
The recommended software for auto-tracking is currently
|
||||
[OpenTracker](https://github.com/trippsc2/OpenTracker/releases).
|
||||
|
||||
### Installation
|
||||
1. Download the appropriate installation file for your computer (Windows users want the `.msi` file).
|
||||
2. During the installation process, you may be asked to install the Microsoft Visual Studio Build Tools. A link
|
||||
to this software is provided during the installation procedure, and it must be installed manually.
|
||||
|
||||
### Enable auto-tracking
|
||||
1. With OpenTracker launched, click the Tracking menu at the top of the window, then choose **AutoTracker...**
|
||||
2. Click the **Get Devices** button
|
||||
3. Select your SNES device from the drop-down list
|
||||
4. If you would like to track small keys and dungeon items, check the box labeled **Race Illegal Tracking**
|
||||
5. Click the **Start Autotracking** button
|
||||
6. Close the AutoTracker window, as it is no longer necessary
|
||||
|
||||
@@ -23,6 +23,7 @@ window.addEventListener('load', () => {
|
||||
games.forEach((game) => {
|
||||
const gameTitle = document.createElement('h2');
|
||||
gameTitle.innerText = game.gameTitle;
|
||||
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
|
||||
tutorialDiv.appendChild(gameTitle);
|
||||
|
||||
game.tutorials.forEach((tutorial) => {
|
||||
@@ -65,7 +66,16 @@ window.addEventListener('load', () => {
|
||||
showError();
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// Check if we are on an anchor when coming in, and scroll to it.
|
||||
const hash = window.location.hash;
|
||||
if (hash) {
|
||||
const offset = 128; // To account for navbar banner at top of page.
|
||||
window.scrollTo(0, 0);
|
||||
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
|
||||
window.scrollTo(rect.left, rect.top - offset);
|
||||
}
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/tutorial/tutorials.json`, true);
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ window.addEventListener('load', () => {
|
||||
"order": [[ 3, "desc" ]],
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
$("#seeds-table").DataTable({
|
||||
"paging": false,
|
||||
@@ -13,5 +14,6 @@ window.addEventListener('load', () => {
|
||||
"order": [[ 2, "desc" ]],
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
});
|
||||
|
||||
1219
WebHostLib/static/assets/weighted-settings.js
Normal file
1219
WebHostLib/static/assets/weighted-settings.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,477 +0,0 @@
|
||||
let spriteData = null;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const gameSettings = document.getElementById('weighted-settings');
|
||||
Promise.all([fetchWeightedSettingsYaml(), fetchWeightedSettingsJson(), fetchSpriteData()]).then((results) => {
|
||||
// Load YAML into object
|
||||
const sourceData = jsyaml.safeLoad(results[0], { json: true });
|
||||
const wsVersion = sourceData.ws_version;
|
||||
delete sourceData.ws_version; // Do not include the settings version number in the export
|
||||
|
||||
// Check if settings exist in localStorage. If no settings are present, this is a first load (or reset to default)
|
||||
// and the version number should be silently updated
|
||||
if (!localStorage.getItem('weightedSettings1')) {
|
||||
localStorage.setItem('wsVersion', wsVersion);
|
||||
}
|
||||
|
||||
// Update localStorage with three settings objects. Preserve original objects if present.
|
||||
for (let i=1; i<=3; i++) {
|
||||
const localSettings = JSON.parse(localStorage.getItem(`weightedSettings${i}`));
|
||||
const updatedObj = localSettings ? Object.assign(sourceData, localSettings) : sourceData;
|
||||
localStorage.setItem(`weightedSettings${i}`, JSON.stringify(updatedObj));
|
||||
}
|
||||
|
||||
// Build the entire UI
|
||||
buildUI(JSON.parse(results[1]), JSON.parse(results[2]));
|
||||
|
||||
// Populate the UI and add event listeners
|
||||
populateSettings();
|
||||
document.getElementById('preset-number').addEventListener('change', populateSettings);
|
||||
gameSettings.addEventListener('change', handleOptionChange);
|
||||
gameSettings.addEventListener('keyup', handleOptionChange);
|
||||
|
||||
document.getElementById('export-button').addEventListener('click', exportSettings);
|
||||
document.getElementById('reset-to-default').addEventListener('click', resetToDefaults);
|
||||
adjustHeaderWidth();
|
||||
|
||||
if (localStorage.getItem('wsVersion') !== wsVersion) {
|
||||
const userWarning = document.getElementById('user-warning');
|
||||
const messageSpan = document.createElement('span');
|
||||
messageSpan.innerHTML = "A new version of the weighted settings file is available. Click here to update!" +
|
||||
"<br />Be aware this will also reset your presets, so you should export them now if you want to save them.";
|
||||
userWarning.appendChild(messageSpan);
|
||||
userWarning.style.display = 'block';
|
||||
userWarning.addEventListener('click', resetToDefaults);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
gameSettings.innerHTML = `
|
||||
<h2>Something went wrong while loading your game settings page.</h2>
|
||||
<h2>${error}</h2>
|
||||
<h2><a href="${window.location.origin}">Click here to return to safety!</a></h2>
|
||||
`
|
||||
});
|
||||
document.getElementById('generate-game').addEventListener('click', () => generateGame());
|
||||
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
|
||||
});
|
||||
|
||||
const fetchWeightedSettingsYaml = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status !== 200) {
|
||||
reject("Unable to fetch source yaml file.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/static/weightedSettings.yaml` ,true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
const fetchWeightedSettingsJson = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status !== 200) {
|
||||
reject('Unable to fetch JSON schema file');
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/static/weightedSettings.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
const fetchSpriteData = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status !== 200) {
|
||||
reject('Unable to fetch sprite data.');
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/static/spriteData.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
const handleOptionChange = (event) => {
|
||||
if(!event.target.matches('.setting')) { return; }
|
||||
const presetNumber = document.getElementById('preset-number').value;
|
||||
const settings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`))
|
||||
const settingString = event.target.getAttribute('data-setting');
|
||||
document.getElementById(settingString).innerText = event.target.value;
|
||||
if(getSettingValue(settings, settingString) !== false){
|
||||
const keys = settingString.split('.');
|
||||
switch (keys.length) {
|
||||
case 1:
|
||||
settings[keys[0]] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
break;
|
||||
case 2:
|
||||
settings[keys[0]][keys[1]] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
break;
|
||||
case 3:
|
||||
settings[keys[0]][keys[1]][keys[2]] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown setting string received: ${settingString}`)
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the updated settings object bask to localStorage
|
||||
localStorage.setItem(`weightedSettings${presetNumber}`, JSON.stringify(settings));
|
||||
}else{
|
||||
console.warn(`Unknown setting string received: ${settingString}`)
|
||||
}
|
||||
};
|
||||
|
||||
const populateSettings = () => {
|
||||
buildSpriteOptions();
|
||||
const presetNumber = document.getElementById('preset-number').value;
|
||||
const settings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`))
|
||||
const settingsInputs = Array.from(document.querySelectorAll('.setting'));
|
||||
settingsInputs.forEach((input) => {
|
||||
const settingString = input.getAttribute('data-setting');
|
||||
const settingValue = getSettingValue(settings, settingString);
|
||||
if(settingValue !== false){
|
||||
input.value = settingValue;
|
||||
document.getElementById(settingString).innerText = settingValue;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the value of the settings object, or false if the settings object does not exist
|
||||
* @param settings
|
||||
* @param keyString
|
||||
* @returns {string} | bool
|
||||
*/
|
||||
const getSettingValue = (settings, keyString) => {
|
||||
const keys = keyString.split('.');
|
||||
let currentVal = settings;
|
||||
keys.forEach((key) => {
|
||||
if(typeof(key) === 'string' && currentVal.hasOwnProperty(key)){
|
||||
currentVal = currentVal[key];
|
||||
}else{
|
||||
currentVal = false;
|
||||
}
|
||||
});
|
||||
return currentVal;
|
||||
};
|
||||
|
||||
const exportSettings = () => {
|
||||
const presetNumber = document.getElementById('preset-number').value;
|
||||
const settings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`));
|
||||
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
||||
download(`${settings.description}.yaml`, yamlText);
|
||||
};
|
||||
|
||||
const resetToDefaults = () => {
|
||||
[1, 2, 3].forEach((presetNumber) => localStorage.removeItem(`weightedSettings${presetNumber}`));
|
||||
location.reload();
|
||||
};
|
||||
|
||||
/** 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 buildUI = (settings, spriteData) => {
|
||||
const settingsWrapper = document.getElementById('settings-wrapper');
|
||||
const settingTypes = {
|
||||
gameOptions: 'Game Options',
|
||||
romOptions: 'ROM Options',
|
||||
}
|
||||
|
||||
Object.keys(settingTypes).forEach((settingTypeKey) => {
|
||||
const sectionHeader = document.createElement('h2');
|
||||
sectionHeader.innerText = settingTypes[settingTypeKey];
|
||||
settingsWrapper.appendChild(sectionHeader);
|
||||
|
||||
Object.values(settings[settingTypeKey]).forEach((setting) => {
|
||||
if (typeof(setting.inputType) === 'undefined' || !setting.inputType){
|
||||
console.error(setting);
|
||||
throw new Error('Setting with no inputType specified.');
|
||||
}
|
||||
|
||||
switch(setting.inputType){
|
||||
case 'text':
|
||||
// Currently, all text input is handled manually because there is very little of it
|
||||
return;
|
||||
case 'range':
|
||||
buildRangeSettings(settingsWrapper, setting);
|
||||
return;
|
||||
default:
|
||||
console.error(setting);
|
||||
throw new Error('Unhandled inputType specified.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Build sprite options
|
||||
const spriteOptionsHeader = document.createElement('h2');
|
||||
spriteOptionsHeader.innerText = 'Sprite Options';
|
||||
settingsWrapper.appendChild(spriteOptionsHeader);
|
||||
|
||||
const spriteOptionsWrapper = document.createElement('div');
|
||||
spriteOptionsWrapper.setAttribute('id', 'sprite-options-wrapper');
|
||||
spriteOptionsWrapper.className = 'setting-wrapper';
|
||||
settingsWrapper.appendChild(spriteOptionsWrapper);
|
||||
|
||||
// Append sprite picker
|
||||
settingsWrapper.appendChild(buildSpritePicker(spriteData));
|
||||
};
|
||||
|
||||
const buildSpriteOptions = () => {
|
||||
const spriteOptionsWrapper = document.getElementById('sprite-options-wrapper');
|
||||
|
||||
// Clear the contents of the wrapper div
|
||||
while(spriteOptionsWrapper.firstChild){
|
||||
spriteOptionsWrapper.removeChild(spriteOptionsWrapper.lastChild);
|
||||
}
|
||||
|
||||
const spriteOptionsTitle = document.createElement('span');
|
||||
spriteOptionsTitle.className = 'title-span';
|
||||
spriteOptionsTitle.innerText = 'Alternate Sprites';
|
||||
spriteOptionsWrapper.appendChild(spriteOptionsTitle);
|
||||
|
||||
const spriteOptionsDescription = document.createElement('span');
|
||||
spriteOptionsDescription.className = 'description-span';
|
||||
spriteOptionsDescription.innerHTML = 'Choose an alternate sprite to play the game with. Additional randomization ' +
|
||||
'options are documented in the ' +
|
||||
'<a href="https://github.com/Berserker66/MultiWorld-Utilities/blob/main/playerSettings.yaml#L374">settings file</a>.';
|
||||
spriteOptionsWrapper.appendChild(spriteOptionsDescription);
|
||||
|
||||
const spriteOptionsTable = document.createElement('table');
|
||||
spriteOptionsTable.setAttribute('id', 'sprite-options-table');
|
||||
spriteOptionsTable.className = 'option-set';
|
||||
const tbody = document.createElement('tbody');
|
||||
tbody.setAttribute('id', 'sprites-tbody');
|
||||
|
||||
const currentPreset = document.getElementById('preset-number').value;
|
||||
const playerSettings = JSON.parse(localStorage.getItem(`weightedSettings${currentPreset}`));
|
||||
|
||||
// Manually add a row for random sprites
|
||||
addSpriteRow(tbody, playerSettings, 'random');
|
||||
|
||||
// Add a row for each sprite currently present in the player's settings
|
||||
Object.keys(playerSettings.rom.sprite).forEach((spriteName) => {
|
||||
if(['random'].indexOf(spriteName) > -1) return;
|
||||
addSpriteRow(tbody, playerSettings, spriteName)
|
||||
});
|
||||
|
||||
spriteOptionsTable.appendChild(tbody);
|
||||
spriteOptionsWrapper.appendChild(spriteOptionsTable);
|
||||
};
|
||||
|
||||
const buildRangeSettings = (parentElement, settings) => {
|
||||
// Ensure we are operating on a range-specific setting
|
||||
if(typeof(settings.inputType) === 'undefined' || settings.inputType !== 'range'){
|
||||
throw new Error('Invalid input type provided to buildRangeSettings func.');
|
||||
}
|
||||
|
||||
const settingWrapper = document.createElement('div');
|
||||
settingWrapper.className = 'setting-wrapper';
|
||||
|
||||
if(typeof(settings.friendlyName) !== 'undefined' && settings.friendlyName){
|
||||
const sectionTitle = document.createElement('span');
|
||||
sectionTitle.className = 'title-span';
|
||||
sectionTitle.innerText = settings.friendlyName;
|
||||
settingWrapper.appendChild(sectionTitle);
|
||||
}
|
||||
|
||||
if(settings.description){
|
||||
const description = document.createElement('span');
|
||||
description.className = 'description-span';
|
||||
description.innerText = settings.description;
|
||||
settingWrapper.appendChild(description);
|
||||
}
|
||||
|
||||
// Create table
|
||||
const optionSetTable = document.createElement('table');
|
||||
optionSetTable.className = 'option-set';
|
||||
|
||||
// Create table body
|
||||
const tbody = document.createElement('tbody');
|
||||
Object.keys(settings.subOptions).forEach((setting) => {
|
||||
// Overwrite setting key name with real object
|
||||
setting = settings.subOptions[setting];
|
||||
const settingId = (Math.random() * 1000000).toString();
|
||||
|
||||
// Create rows for each option
|
||||
const optionRow = document.createElement('tr');
|
||||
|
||||
// Option name td
|
||||
const optionName = document.createElement('td');
|
||||
optionName.className = 'option-name';
|
||||
const optionLabel = document.createElement('label');
|
||||
optionLabel.setAttribute('for', settingId);
|
||||
optionLabel.setAttribute('data-tooltip', setting.description);
|
||||
optionLabel.innerText = setting.friendlyName;
|
||||
optionName.appendChild(optionLabel);
|
||||
optionRow.appendChild(optionName);
|
||||
|
||||
// Option value td
|
||||
const optionValue = document.createElement('td');
|
||||
optionValue.className = 'option-value';
|
||||
const input = document.createElement('input');
|
||||
input.className = 'setting';
|
||||
input.setAttribute('id', settingId);
|
||||
input.setAttribute('type', 'range');
|
||||
input.setAttribute('min', '0');
|
||||
input.setAttribute('max', '100');
|
||||
input.setAttribute('data-setting', setting.keyString);
|
||||
input.value = setting.defaultValue;
|
||||
optionValue.appendChild(input);
|
||||
const valueDisplay = document.createElement('span');
|
||||
valueDisplay.setAttribute('id', setting.keyString);
|
||||
valueDisplay.innerText = setting.defaultValue;
|
||||
optionValue.appendChild(valueDisplay);
|
||||
optionRow.appendChild(optionValue);
|
||||
tbody.appendChild(optionRow);
|
||||
});
|
||||
|
||||
optionSetTable.appendChild(tbody);
|
||||
settingWrapper.appendChild(optionSetTable);
|
||||
parentElement.appendChild(settingWrapper);
|
||||
};
|
||||
|
||||
const addSpriteRow = (tbody, playerSettings, spriteName) => {
|
||||
const rowId = (Math.random() * 1000000).toString();
|
||||
const optionId = (Math.random() * 1000000).toString();
|
||||
const tr = document.createElement('tr');
|
||||
tr.setAttribute('id', rowId);
|
||||
|
||||
// Option Name
|
||||
const optionName = document.createElement('td');
|
||||
optionName.className = 'option-name';
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = optionId;
|
||||
label.innerText = spriteName;
|
||||
optionName.appendChild(label);
|
||||
|
||||
if(['random', 'random_sprite_on_event'].indexOf(spriteName) === -1) {
|
||||
const deleteButton = document.createElement('span');
|
||||
deleteButton.setAttribute('data-sprite', spriteName);
|
||||
deleteButton.setAttribute('data-row-id', rowId);
|
||||
deleteButton.innerText = ' (❌)';
|
||||
deleteButton.className = 'delete-button';
|
||||
optionName.appendChild(deleteButton);
|
||||
deleteButton.addEventListener('click', removeSpriteOption);
|
||||
}
|
||||
|
||||
tr.appendChild(optionName);
|
||||
|
||||
// Option Value
|
||||
const optionValue = document.createElement('td');
|
||||
optionValue.className = 'option-value';
|
||||
const input = document.createElement('input');
|
||||
input.className = 'setting';
|
||||
input.setAttribute('id', optionId);
|
||||
input.setAttribute('type', 'range');
|
||||
input.setAttribute('min', '0');
|
||||
input.setAttribute('max', '100');
|
||||
input.setAttribute('data-setting', `rom.sprite.${spriteName}`);
|
||||
input.value = "50";
|
||||
optionValue.appendChild(input);
|
||||
|
||||
// Value display
|
||||
const valueDisplay = document.createElement('span');
|
||||
valueDisplay.setAttribute('id', `rom.sprite.${spriteName}`);
|
||||
valueDisplay.innerText = playerSettings.rom.sprite.hasOwnProperty(spriteName) ?
|
||||
playerSettings.rom.sprite[spriteName] : '0';
|
||||
optionValue.appendChild(valueDisplay);
|
||||
|
||||
tr.appendChild(optionValue);
|
||||
tbody.appendChild(tr);
|
||||
};
|
||||
|
||||
const addSpriteOption = (event) => {
|
||||
const presetNumber = document.getElementById('preset-number').value;
|
||||
const playerSettings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`));
|
||||
const spriteName = event.target.getAttribute('data-sprite');
|
||||
|
||||
if (Object.keys(playerSettings.rom.sprite).indexOf(spriteName) !== -1) {
|
||||
// Do not add the same sprite twice
|
||||
return;
|
||||
}
|
||||
|
||||
// Add option to playerSettings object
|
||||
playerSettings.rom.sprite[event.target.getAttribute('data-sprite')] = 50;
|
||||
localStorage.setItem(`weightedSettings${presetNumber}`, JSON.stringify(playerSettings));
|
||||
|
||||
// Add <tr> to #sprite-options-table
|
||||
const tbody = document.getElementById('sprites-tbody');
|
||||
addSpriteRow(tbody, playerSettings, spriteName);
|
||||
};
|
||||
|
||||
const removeSpriteOption = (event) => {
|
||||
const presetNumber = document.getElementById('preset-number').value;
|
||||
const playerSettings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`));
|
||||
const spriteName = event.target.getAttribute('data-sprite');
|
||||
|
||||
// Remove option from playerSettings object
|
||||
delete playerSettings.rom.sprite[spriteName];
|
||||
localStorage.setItem(`weightedSettings${presetNumber}`, JSON.stringify(playerSettings));
|
||||
|
||||
// Remove <tr> from #sprite-options-table
|
||||
const tr = document.getElementById(event.target.getAttribute('data-row-id'));
|
||||
tr.parentNode.removeChild(tr);
|
||||
};
|
||||
|
||||
const buildSpritePicker = (spriteData) => {
|
||||
const spritePicker = document.createElement('div');
|
||||
spritePicker.setAttribute('id', 'sprite-picker');
|
||||
|
||||
// Build description
|
||||
const description = document.createElement('span');
|
||||
description.innerText = 'To add a sprite to your playable list, click the one you want below.';
|
||||
spritePicker.appendChild(description);
|
||||
|
||||
const sprites = document.createElement('div');
|
||||
sprites.setAttribute('id', 'sprite-picker-sprites');
|
||||
spriteData.sprites.forEach((sprite) => {
|
||||
const spriteImg = document.createElement('img');
|
||||
let spriteGifFile = sprite.file.split('.');
|
||||
spriteGifFile.pop();
|
||||
spriteGifFile = spriteGifFile.join('.') + '.gif';
|
||||
spriteImg.setAttribute('src', `static/static/sprites/${spriteGifFile}`);
|
||||
spriteImg.setAttribute('data-sprite', sprite.file.split('.')[0]);
|
||||
spriteImg.setAttribute('alt', sprite.name);
|
||||
|
||||
// Wrap the image in a span to allow for tooltip presence
|
||||
const imgWrapper = document.createElement('span');
|
||||
imgWrapper.className = 'sprite-img-wrapper';
|
||||
imgWrapper.setAttribute('data-tooltip', `${sprite.name}${sprite.author ? `, by ${sprite.author}` : ''}`);
|
||||
imgWrapper.appendChild(spriteImg);
|
||||
imgWrapper.setAttribute('data-sprite', sprite.name);
|
||||
sprites.appendChild(imgWrapper);
|
||||
imgWrapper.addEventListener('click', addSpriteOption);
|
||||
});
|
||||
|
||||
spritePicker.appendChild(sprites);
|
||||
return spritePicker;
|
||||
};
|
||||
|
||||
const generateGame = (raceMode = false) => {
|
||||
const presetNumber = document.getElementById('preset-number').value;
|
||||
axios.post('/api/generate', {
|
||||
weights: { player: localStorage.getItem(`weightedSettings${presetNumber}`) },
|
||||
presetData: { player: localStorage.getItem(`weightedSettings${presetNumber}`) },
|
||||
playerCount: 1,
|
||||
race: raceMode ? '1' : '0',
|
||||
}).then((response) => {
|
||||
window.location.href = response.data.url;
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
Copyright 2020 Berserker66 (Fabian Dill)
|
||||
Copyright 2020 LegendaryLinux (Chris Wilson)
|
||||
Copyright 2022 LegendaryLinux (Chris Wilson)
|
||||
|
||||
All rights reserved.
|
||||
|
||||
BIN
WebHostLib/static/static/backgrounds/dirt.png
Normal file
BIN
WebHostLib/static/static/backgrounds/dirt.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user