mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-23 07:43:22 -07:00
Compare commits
860 Commits
style-lock
...
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 |
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 }}
|
||||||
64
.github/workflows/build.yml
vendored
64
.github/workflows/build.yml
vendored
@@ -2,7 +2,22 @@
|
|||||||
|
|
||||||
name: Build
|
name: Build
|
||||||
|
|
||||||
on: workflow_dispatch
|
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:
|
jobs:
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
@@ -10,22 +25,19 @@ jobs:
|
|||||||
build-win-py38: # RCs will still be built and signed by hand
|
build-win-py38: # RCs will still be built and signed by hand
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Install python
|
- name: Install python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.8'
|
python-version: '3.8'
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-windows-amd64.zip -OutFile sni.zip
|
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||||
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
|
|
||||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/7.0.1/win-x64.zip -OutFile enemizer.zip
|
|
||||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip setuptools
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
python setup.py build_exe --yes
|
||||||
python setup.py build --yes
|
|
||||||
$NAME="$(ls build)".Split('.',2)[1]
|
$NAME="$(ls build)".Split('.',2)[1]
|
||||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||||
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
||||||
@@ -34,49 +46,46 @@ jobs:
|
|||||||
Rename-Item exe.$NAME Archipelago
|
Rename-Item exe.$NAME Archipelago
|
||||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||||
- name: Store 7z
|
- name: Store 7z
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ${{ env.ZIP_NAME }}
|
name: ${{ env.ZIP_NAME }}
|
||||||
path: dist/${{ env.ZIP_NAME }}
|
path: dist/${{ env.ZIP_NAME }}
|
||||||
retention-days: 7 # keep for 7 days, should be enough
|
retention-days: 7 # keep for 7 days, should be enough
|
||||||
|
|
||||||
build-ubuntu1804:
|
build-ubuntu2004:
|
||||||
runs-on: ubuntu-18.04
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
# - copy code below to release.yml -
|
||||||
|
- uses: actions/checkout@v3
|
||||||
- name: Install base dependencies
|
- name: Install base dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
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
|
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||||
- name: Get a recent python
|
- name: Get a recent python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.9'
|
python-version: '3.9'
|
||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz
|
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
|
||||||
tar xf sni-*.tar.xz
|
|
||||||
rm sni-*.tar.xz
|
|
||||||
mv sni-* SNI
|
|
||||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z
|
|
||||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
# pygobject is an optional dependency for kivy that's not in requirements
|
# pygobject is an optional dependency for kivy that's not in requirements
|
||||||
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
|
# charset-normalizer was somehow incomplete in the github runner
|
||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
||||||
python setup.py build --yes bdist_appimage --yes
|
python setup.py build_exe --yes bdist_appimage --yes
|
||||||
echo -e "setup.py build output:\n `ls build`"
|
echo -e "setup.py build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||||
@@ -84,14 +93,19 @@ jobs:
|
|||||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
(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 "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
|
# - copy code above to release.yml -
|
||||||
|
- name: Build Again
|
||||||
|
run: |
|
||||||
|
source venv/bin/activate
|
||||||
|
python setup.py build_exe --yes
|
||||||
- name: Store AppImage
|
- name: Store AppImage
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ${{ env.APPIMAGE_NAME }}
|
name: ${{ env.APPIMAGE_NAME }}
|
||||||
path: dist/${{ env.APPIMAGE_NAME }}
|
path: dist/${{ env.APPIMAGE_NAME }}
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: Store .tar.gz
|
- name: Store .tar.gz
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: ${{ env.TAR_NAME }}
|
name: ${{ env.TAR_NAME }}
|
||||||
path: dist/${{ env.TAR_NAME }}
|
path: dist/${{ env.TAR_NAME }}
|
||||||
|
|||||||
16
.github/workflows/codeql-analysis.yml
vendored
16
.github/workflows/codeql-analysis.yml
vendored
@@ -14,9 +14,17 @@ name: "CodeQL"
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
paths:
|
||||||
|
- '**.py'
|
||||||
|
- '**.js'
|
||||||
|
- '.github/workflows/codeql-analysis.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
# The branches below must be a subset of the branches above
|
# The branches below must be a subset of the branches above
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
paths:
|
||||||
|
- '**.py'
|
||||||
|
- '**.js'
|
||||||
|
- '.github/workflows/codeql-analysis.yml'
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '44 8 * * 1'
|
- cron: '44 8 * * 1'
|
||||||
|
|
||||||
@@ -35,11 +43,11 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v1
|
uses: github/codeql-action/init@v2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# 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).
|
# 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)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v1
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 https://git.io/JvXDl
|
# 📚 https://git.io/JvXDl
|
||||||
@@ -64,4 +72,4 @@ jobs:
|
|||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- 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.9
|
|
||||||
uses: actions/setup-python@v1
|
|
||||||
with:
|
|
||||||
python-version: 3.9
|
|
||||||
- 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
|
|
||||||
27
.github/workflows/release.yml
vendored
27
.github/workflows/release.yml
vendored
@@ -7,6 +7,10 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- '*.*.*'
|
- '*.*.*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
ENEMIZER_VERSION: 7.1
|
||||||
|
APPIMAGETOOL_VERSION: 13
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
create-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -25,45 +29,42 @@ jobs:
|
|||||||
# build-release-windows: # this is done by hand because of signing
|
# build-release-windows: # this is done by hand because of signing
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
|
|
||||||
build-release-ubuntu1804:
|
build-release-ubuntu2004:
|
||||||
runs-on: ubuntu-18.04
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
- name: Set env
|
- name: Set env
|
||||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
# - code below copied from build.yml -
|
# - code below copied from build.yml -
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Install base dependencies
|
- name: Install base dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
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
|
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||||
- name: Get a recent python
|
- name: Get a recent python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.9'
|
python-version: '3.9'
|
||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz
|
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
|
||||||
tar xf sni-*.tar.xz
|
|
||||||
rm sni-*.tar.xz
|
|
||||||
mv sni-* SNI
|
|
||||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z
|
|
||||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
"${{ env.PYTHON }}" -m pip install --upgrade pip setuptools virtualenv PyGObject # pygobject should probably move to requirements
|
# 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
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
||||||
python setup.py build --yes bdist_appimage --yes
|
python setup.py build_exe --yes bdist_appimage --yes
|
||||||
echo -e "setup.py build output:\n `ls build`"
|
echo -e "setup.py build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||||
|
|||||||
30
.github/workflows/unittests.yml
vendored
30
.github/workflows/unittests.yml
vendored
@@ -3,7 +3,25 @@
|
|||||||
|
|
||||||
name: unittests
|
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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -23,18 +41,20 @@ jobs:
|
|||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.10'} # current
|
- python: {version: '3.10'} # current
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
|
- python: {version: '3.10'} # current
|
||||||
|
os: macos-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python ${{ matrix.python.version }}
|
- name: Set up Python ${{ matrix.python.version }}
|
||||||
uses: actions/setup-python@v1
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python.version }}
|
python-version: ${{ matrix.python.version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install flake8 pytest
|
pip install pytest pytest-subtests
|
||||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||||
- name: Unittests
|
- name: Unittests
|
||||||
run: |
|
run: |
|
||||||
pytest test
|
pytest
|
||||||
|
|||||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -4,15 +4,21 @@
|
|||||||
*_Spoiler.txt
|
*_Spoiler.txt
|
||||||
*.bmbp
|
*.bmbp
|
||||||
*.apbp
|
*.apbp
|
||||||
|
*.apl2ac
|
||||||
*.apm3
|
*.apm3
|
||||||
*.apmc
|
*.apmc
|
||||||
*.apz5
|
*.apz5
|
||||||
|
*.aptloz
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyd
|
*.pyd
|
||||||
*.sfc
|
*.sfc
|
||||||
*.z64
|
*.z64
|
||||||
*.n64
|
*.n64
|
||||||
*.nes
|
*.nes
|
||||||
|
*.sms
|
||||||
|
*.gb
|
||||||
|
*.gbc
|
||||||
|
*.gba
|
||||||
*.wixobj
|
*.wixobj
|
||||||
*.lck
|
*.lck
|
||||||
*.db3
|
*.db3
|
||||||
@@ -20,7 +26,9 @@
|
|||||||
*multisave
|
*multisave
|
||||||
*.archipelago
|
*.archipelago
|
||||||
*.apsave
|
*.apsave
|
||||||
|
*.BIN
|
||||||
|
|
||||||
|
setups
|
||||||
build
|
build
|
||||||
bundle/components.wxs
|
bundle/components.wxs
|
||||||
dist
|
dist
|
||||||
@@ -28,6 +36,7 @@ README.html
|
|||||||
.vs/
|
.vs/
|
||||||
EnemizerCLI/
|
EnemizerCLI/
|
||||||
/Players/
|
/Players/
|
||||||
|
/SNI/
|
||||||
/options.yaml
|
/options.yaml
|
||||||
/config.yaml
|
/config.yaml
|
||||||
/logs/
|
/logs/
|
||||||
@@ -43,7 +52,9 @@ Output Logs/
|
|||||||
/freeze_requirements.txt
|
/freeze_requirements.txt
|
||||||
/Archipelago.zip
|
/Archipelago.zip
|
||||||
/setup.ini
|
/setup.ini
|
||||||
|
/installdelete.iss
|
||||||
|
/data/user.kv
|
||||||
|
/datapackage
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -124,12 +135,14 @@ ipython_config.py
|
|||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
.venv
|
.venv*
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
.code-workspace
|
||||||
|
shell.nix
|
||||||
|
|
||||||
# Spyder project settings
|
# Spyder project settings
|
||||||
.spyderproject
|
.spyderproject
|
||||||
@@ -159,10 +172,14 @@ cython_debug/
|
|||||||
jdk*/
|
jdk*/
|
||||||
minecraft*/
|
minecraft*/
|
||||||
minecraft_versions.json
|
minecraft_versions.json
|
||||||
|
!worlds/minecraft/
|
||||||
|
|
||||||
# pyenv
|
# pyenv
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
|
#undertale stuff
|
||||||
|
/Undertale/
|
||||||
|
|
||||||
# OS General Files
|
# OS General Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.AppleDouble
|
.AppleDouble
|
||||||
|
|||||||
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()
|
||||||
1002
BaseClasses.py
1002
BaseClasses.py
File diff suppressed because it is too large
Load Diff
333
CommonClient.py
333
CommonClient.py
@@ -5,6 +5,7 @@ import urllib.parse
|
|||||||
import sys
|
import sys
|
||||||
import typing
|
import typing
|
||||||
import time
|
import time
|
||||||
|
import functools
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
@@ -17,10 +18,15 @@ if __name__ == "__main__":
|
|||||||
Utils.init_logging("TextClient", exception_logger="Client")
|
Utils.init_logging("TextClient", exception_logger="Client")
|
||||||
|
|
||||||
from MultiServer import CommandProcessor
|
from MultiServer import CommandProcessor
|
||||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot
|
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
|
||||||
from Utils import Version, stream_input
|
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
|
||||||
|
from Utils import Version, stream_input, async_start
|
||||||
from worlds import network_data_package, AutoWorldRegister
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
import os
|
import os
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
import kvui
|
||||||
|
|
||||||
logger = logging.getLogger("Client")
|
logger = logging.getLogger("Client")
|
||||||
|
|
||||||
@@ -28,6 +34,12 @@ logger = logging.getLogger("Client")
|
|||||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
||||||
|
|
||||||
|
|
||||||
|
@Utils.cache_argsless
|
||||||
|
def get_ssl_context():
|
||||||
|
import certifi
|
||||||
|
return ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where())
|
||||||
|
|
||||||
|
|
||||||
class ClientCommandProcessor(CommandProcessor):
|
class ClientCommandProcessor(CommandProcessor):
|
||||||
def __init__(self, ctx: CommonContext):
|
def __init__(self, ctx: CommonContext):
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
@@ -42,33 +54,38 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
|
|
||||||
def _cmd_connect(self, address: str = "") -> bool:
|
def _cmd_connect(self, address: str = "") -> bool:
|
||||||
"""Connect to a MultiWorld Server"""
|
"""Connect to a MultiWorld Server"""
|
||||||
self.ctx.server_address = None
|
if address:
|
||||||
self.ctx.username = None
|
self.ctx.server_address = None
|
||||||
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
|
self.ctx.username = None
|
||||||
|
elif not self.ctx.server_address:
|
||||||
|
self.output("Please specify an address.")
|
||||||
|
return False
|
||||||
|
async_start(self.ctx.connect(address if address else None), name="connecting")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_disconnect(self) -> bool:
|
def _cmd_disconnect(self) -> bool:
|
||||||
"""Disconnect from a MultiWorld Server"""
|
"""Disconnect from a MultiWorld Server"""
|
||||||
self.ctx.server_address = None
|
async_start(self.ctx.disconnect(), name="disconnecting")
|
||||||
self.ctx.username = None
|
|
||||||
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_received(self) -> bool:
|
def _cmd_received(self) -> bool:
|
||||||
"""List all received items"""
|
"""List all received items"""
|
||||||
logger.info(f'{len(self.ctx.items_received)} received items:')
|
self.output(f'{len(self.ctx.items_received)} received items:')
|
||||||
for index, item in enumerate(self.ctx.items_received, 1):
|
for index, item in enumerate(self.ctx.items_received, 1):
|
||||||
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
|
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_missing(self) -> bool:
|
def _cmd_missing(self, filter_text = "") -> bool:
|
||||||
"""List all missing location checks, from your local game state"""
|
"""List all missing location checks, from your local game state.
|
||||||
|
Can be given text, which will be used as filter."""
|
||||||
if not self.ctx.game:
|
if not self.ctx.game:
|
||||||
self.output("No game set, cannot determine missing checks.")
|
self.output("No game set, cannot determine missing checks.")
|
||||||
return False
|
return False
|
||||||
count = 0
|
count = 0
|
||||||
checked_count = 0
|
checked_count = 0
|
||||||
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
||||||
|
if filter_text and filter_text not in location:
|
||||||
|
continue
|
||||||
if location_id < 0:
|
if location_id < 0:
|
||||||
continue
|
continue
|
||||||
if location_id not in self.ctx.locations_checked:
|
if location_id not in self.ctx.locations_checked:
|
||||||
@@ -89,12 +106,18 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
|
|
||||||
def _cmd_items(self):
|
def _cmd_items(self):
|
||||||
"""List all item names for the currently running game."""
|
"""List all item names for the currently running game."""
|
||||||
|
if not self.ctx.game:
|
||||||
|
self.output("No game set, cannot determine existing items.")
|
||||||
|
return False
|
||||||
self.output(f"Item Names for {self.ctx.game}")
|
self.output(f"Item Names for {self.ctx.game}")
|
||||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
||||||
self.output(item_name)
|
self.output(item_name)
|
||||||
|
|
||||||
def _cmd_locations(self):
|
def _cmd_locations(self):
|
||||||
"""List all location names for the currently running game."""
|
"""List all location names for the currently running game."""
|
||||||
|
if not self.ctx.game:
|
||||||
|
self.output("No game set, cannot determine existing locations.")
|
||||||
|
return False
|
||||||
self.output(f"Location Names for {self.ctx.game}")
|
self.output(f"Location Names for {self.ctx.game}")
|
||||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
||||||
self.output(location_name)
|
self.output(location_name)
|
||||||
@@ -108,12 +131,12 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
else:
|
else:
|
||||||
state = ClientStatus.CLIENT_CONNECTED
|
state = ClientStatus.CLIENT_CONNECTED
|
||||||
self.output("Unreadied.")
|
self.output("Unreadied.")
|
||||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||||
|
|
||||||
def default(self, raw: str):
|
def default(self, raw: str):
|
||||||
raw = self.ctx.on_user_say(raw)
|
raw = self.ctx.on_user_say(raw)
|
||||||
if raw:
|
if raw:
|
||||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||||
|
|
||||||
|
|
||||||
class CommonContext:
|
class CommonContext:
|
||||||
@@ -121,8 +144,9 @@ class CommonContext:
|
|||||||
tags: typing.Set[str] = {"AP"}
|
tags: typing.Set[str] = {"AP"}
|
||||||
game: typing.Optional[str] = None
|
game: typing.Optional[str] = None
|
||||||
items_handling: typing.Optional[int] = None
|
items_handling: typing.Optional[int] = None
|
||||||
|
want_slot_data: bool = True # should slot_data be retrieved via Connect
|
||||||
|
|
||||||
# datapackage
|
# data package
|
||||||
# Contents in flux until connection to server is made, to download correct data for this multiworld.
|
# Contents in flux until connection to server is made, to download correct data for this multiworld.
|
||||||
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||||
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||||
@@ -130,37 +154,54 @@ class CommonContext:
|
|||||||
# defaults
|
# defaults
|
||||||
starting_reconnect_delay: int = 5
|
starting_reconnect_delay: int = 5
|
||||||
current_reconnect_delay: int = starting_reconnect_delay
|
current_reconnect_delay: int = starting_reconnect_delay
|
||||||
command_processor: type(CommandProcessor) = ClientCommandProcessor
|
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
||||||
ui = None
|
ui = None
|
||||||
ui_task: typing.Optional[asyncio.Task] = None
|
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
||||||
input_task: typing.Optional[asyncio.Task] = None
|
input_task: typing.Optional["asyncio.Task[None]"] = None
|
||||||
keep_alive_task: typing.Optional[asyncio.Task] = None
|
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
|
||||||
server_task: typing.Optional[asyncio.Task] = None
|
server_task: typing.Optional["asyncio.Task[None]"] = None
|
||||||
|
autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None
|
||||||
|
disconnected_intentionally: bool = False
|
||||||
server: typing.Optional[Endpoint] = None
|
server: typing.Optional[Endpoint] = None
|
||||||
server_version: Version = Version(0, 0, 0)
|
server_version: Version = Version(0, 0, 0)
|
||||||
current_energy_link_value: int = 0 # to display in UI, gets set by server
|
generator_version: Version = Version(0, 0, 0)
|
||||||
|
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
|
||||||
|
|
||||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||||
|
|
||||||
# remaining type info
|
# remaining type info
|
||||||
slot_info: typing.Dict[int, NetworkSlot]
|
slot_info: typing.Dict[int, NetworkSlot]
|
||||||
server_address: str
|
server_address: typing.Optional[str]
|
||||||
password: typing.Optional[str]
|
password: typing.Optional[str]
|
||||||
hint_cost: typing.Optional[int]
|
hint_cost: typing.Optional[int]
|
||||||
|
hint_points: typing.Optional[int]
|
||||||
player_names: typing.Dict[int, str]
|
player_names: typing.Dict[int, str]
|
||||||
|
|
||||||
|
finished_game: bool
|
||||||
|
ready: bool
|
||||||
|
auth: typing.Optional[str]
|
||||||
|
seed_name: typing.Optional[str]
|
||||||
|
|
||||||
# locations
|
# locations
|
||||||
locations_checked: typing.Set[int] # local state
|
locations_checked: typing.Set[int] # local state
|
||||||
locations_scouted: typing.Set[int]
|
locations_scouted: typing.Set[int]
|
||||||
missing_locations: typing.Set[int]
|
items_received: typing.List[NetworkItem]
|
||||||
|
missing_locations: typing.Set[int] # server state
|
||||||
checked_locations: typing.Set[int] # server state
|
checked_locations: typing.Set[int] # server state
|
||||||
|
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
||||||
locations_info: typing.Dict[int, NetworkItem]
|
locations_info: typing.Dict[int, NetworkItem]
|
||||||
|
|
||||||
|
# data storage
|
||||||
|
stored_data: typing.Dict[str, typing.Any]
|
||||||
|
stored_data_notification_keys: typing.Set[str]
|
||||||
|
|
||||||
# internals
|
# internals
|
||||||
# current message box through kvui
|
# current message box through kvui
|
||||||
_messagebox = None
|
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
||||||
|
# message box reporting a loss of connection
|
||||||
|
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
||||||
|
|
||||||
def __init__(self, server_address, password):
|
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
|
||||||
# server state
|
# server state
|
||||||
self.server_address = server_address
|
self.server_address = server_address
|
||||||
self.username = None
|
self.username = None
|
||||||
@@ -168,7 +209,7 @@ class CommonContext:
|
|||||||
self.hint_cost = None
|
self.hint_cost = None
|
||||||
self.slot_info = {}
|
self.slot_info = {}
|
||||||
self.permissions = {
|
self.permissions = {
|
||||||
"forfeit": "disabled",
|
"release": "disabled",
|
||||||
"collect": "disabled",
|
"collect": "disabled",
|
||||||
"remaining": "disabled",
|
"remaining": "disabled",
|
||||||
}
|
}
|
||||||
@@ -184,10 +225,14 @@ class CommonContext:
|
|||||||
self.locations_checked = set() # local state
|
self.locations_checked = set() # local state
|
||||||
self.locations_scouted = set()
|
self.locations_scouted = set()
|
||||||
self.items_received = []
|
self.items_received = []
|
||||||
self.missing_locations = set()
|
self.missing_locations = set() # server state
|
||||||
self.checked_locations = set() # server state
|
self.checked_locations = set() # server state
|
||||||
|
self.server_locations = set() # all locations the server knows of, missing_location | checked_locations
|
||||||
self.locations_info = {}
|
self.locations_info = {}
|
||||||
|
|
||||||
|
self.stored_data = {}
|
||||||
|
self.stored_data_notification_keys = set()
|
||||||
|
|
||||||
self.input_queue = asyncio.Queue()
|
self.input_queue = asyncio.Queue()
|
||||||
self.input_requests = 0
|
self.input_requests = 0
|
||||||
|
|
||||||
@@ -197,11 +242,21 @@ class CommonContext:
|
|||||||
self.watcher_event = asyncio.Event()
|
self.watcher_event = asyncio.Event()
|
||||||
|
|
||||||
self.jsontotextparser = JSONtoTextParser(self)
|
self.jsontotextparser = JSONtoTextParser(self)
|
||||||
self.update_datapackage(network_data_package)
|
self.update_data_package(network_data_package)
|
||||||
|
|
||||||
# execution
|
# execution
|
||||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def suggested_address(self) -> str:
|
||||||
|
if self.server_address:
|
||||||
|
return self.server_address
|
||||||
|
return Utils.persistent_load().get("client", {}).get("last_server_address", "")
|
||||||
|
|
||||||
|
@functools.cached_property
|
||||||
|
def raw_text_parser(self) -> RawJSONtoTextParser:
|
||||||
|
return RawJSONtoTextParser(self)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_locations(self) -> typing.Optional[int]:
|
def total_locations(self) -> typing.Optional[int]:
|
||||||
"""Will return None until connected."""
|
"""Will return None until connected."""
|
||||||
@@ -209,9 +264,9 @@ class CommonContext:
|
|||||||
return len(self.checked_locations | self.missing_locations)
|
return len(self.checked_locations | self.missing_locations)
|
||||||
|
|
||||||
async def connection_closed(self):
|
async def connection_closed(self):
|
||||||
self.reset_server_state()
|
|
||||||
if self.server and self.server.socket is not None:
|
if self.server and self.server.socket is not None:
|
||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
|
self.reset_server_state()
|
||||||
|
|
||||||
def reset_server_state(self):
|
def reset_server_state(self):
|
||||||
self.auth = None
|
self.auth = None
|
||||||
@@ -220,22 +275,28 @@ class CommonContext:
|
|||||||
self.items_received = []
|
self.items_received = []
|
||||||
self.locations_info = {}
|
self.locations_info = {}
|
||||||
self.server_version = Version(0, 0, 0)
|
self.server_version = Version(0, 0, 0)
|
||||||
|
self.generator_version = Version(0, 0, 0)
|
||||||
self.server = None
|
self.server = None
|
||||||
self.server_task = None
|
self.server_task = None
|
||||||
self.hint_cost = None
|
self.hint_cost = None
|
||||||
self.permissions = {
|
self.permissions = {
|
||||||
"forfeit": "disabled",
|
"release": "disabled",
|
||||||
"collect": "disabled",
|
"collect": "disabled",
|
||||||
"remaining": "disabled",
|
"remaining": "disabled",
|
||||||
}
|
}
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||||
|
if not allow_autoreconnect:
|
||||||
|
self.disconnected_intentionally = True
|
||||||
|
if self.cancel_autoreconnect():
|
||||||
|
logger.info("Cancelled auto-reconnect.")
|
||||||
if self.server and not self.server.socket.closed:
|
if self.server and not self.server.socket.closed:
|
||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task is not None:
|
if self.server_task is not None:
|
||||||
await self.server_task
|
await self.server_task
|
||||||
|
|
||||||
async def send_msgs(self, msgs):
|
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
||||||
|
""" `msgs` JSON serializable """
|
||||||
if not self.server or not self.server.socket.open or self.server.socket.closed:
|
if not self.server or not self.server.socket.open or self.server.socket.closed:
|
||||||
return
|
return
|
||||||
await self.server.socket.send(encode(msgs))
|
await self.server.socket.send(encode(msgs))
|
||||||
@@ -263,25 +324,36 @@ class CommonContext:
|
|||||||
logger.info('Enter slot name:')
|
logger.info('Enter slot name:')
|
||||||
self.auth = await self.console_input()
|
self.auth = await self.console_input()
|
||||||
|
|
||||||
async def send_connect(self, **kwargs):
|
async def send_connect(self, **kwargs: typing.Any) -> None:
|
||||||
|
""" send `Connect` packet to log in to server """
|
||||||
payload = {
|
payload = {
|
||||||
'cmd': 'Connect',
|
'cmd': 'Connect',
|
||||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||||
'tags': self.tags, 'items_handling': self.items_handling,
|
'tags': self.tags, 'items_handling': self.items_handling,
|
||||||
'uuid': Utils.get_unique_identifier(), 'game': self.game
|
'uuid': Utils.get_unique_identifier(), 'game': self.game, "slot_data": self.want_slot_data,
|
||||||
}
|
}
|
||||||
if kwargs:
|
if kwargs:
|
||||||
payload.update(kwargs)
|
payload.update(kwargs)
|
||||||
await self.send_msgs([payload])
|
await self.send_msgs([payload])
|
||||||
|
|
||||||
async def console_input(self):
|
async def console_input(self) -> str:
|
||||||
|
if self.ui:
|
||||||
|
self.ui.focus_textinput()
|
||||||
self.input_requests += 1
|
self.input_requests += 1
|
||||||
return await self.input_queue.get()
|
return await self.input_queue.get()
|
||||||
|
|
||||||
async def connect(self, address=None):
|
async def connect(self, address: typing.Optional[str] = None) -> None:
|
||||||
|
""" disconnect any previous connection, and open new connection to the server """
|
||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
||||||
|
|
||||||
|
def cancel_autoreconnect(self) -> bool:
|
||||||
|
if self.autoreconnect_task:
|
||||||
|
self.autoreconnect_task.cancel()
|
||||||
|
self.autoreconnect_task = None
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def slot_concerns_self(self, slot) -> bool:
|
def slot_concerns_self(self, slot) -> bool:
|
||||||
if slot == self.slot:
|
if slot == self.slot:
|
||||||
return True
|
return True
|
||||||
@@ -289,6 +361,17 @@ class CommonContext:
|
|||||||
return self.slot in self.slot_info[slot].group_members
|
return self.slot in self.slot_info[slot].group_members
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def is_echoed_chat(self, print_json_packet: dict) -> bool:
|
||||||
|
return print_json_packet.get("type", "") == "Chat" \
|
||||||
|
and print_json_packet.get("team", None) == self.team \
|
||||||
|
and print_json_packet.get("slot", None) == self.slot
|
||||||
|
|
||||||
|
def is_uninteresting_item_send(self, print_json_packet: dict) -> bool:
|
||||||
|
"""Helper function for filtering out ItemSend prints that do not concern the local player."""
|
||||||
|
return print_json_packet.get("type", "") == "ItemSend" \
|
||||||
|
and not self.slot_concerns_self(print_json_packet["receiving"]) \
|
||||||
|
and not self.slot_concerns_self(print_json_packet["item"].player)
|
||||||
|
|
||||||
def on_print(self, args: dict):
|
def on_print(self, args: dict):
|
||||||
logger.info(args["text"])
|
logger.info(args["text"])
|
||||||
|
|
||||||
@@ -320,6 +403,7 @@ class CommonContext:
|
|||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
self.server_address = ""
|
self.server_address = ""
|
||||||
self.username = None
|
self.username = None
|
||||||
|
self.cancel_autoreconnect()
|
||||||
if self.server and not self.server.socket.closed:
|
if self.server and not self.server.socket.closed:
|
||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task:
|
if self.server_task:
|
||||||
@@ -335,30 +419,40 @@ class CommonContext:
|
|||||||
self.input_task.cancel()
|
self.input_task.cancel()
|
||||||
|
|
||||||
# DataPackage
|
# DataPackage
|
||||||
async def prepare_datapackage(self, relevant_games: typing.Set[str],
|
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
||||||
remote_datepackage_versions: typing.Dict[str, int]):
|
remote_date_package_versions: typing.Dict[str, int],
|
||||||
|
remote_data_package_checksums: typing.Dict[str, str]):
|
||||||
"""Validate that all data is present for the current multiworld.
|
"""Validate that all data is present for the current multiworld.
|
||||||
Download, assimilate and cache missing data from the server."""
|
Download, assimilate and cache missing data from the server."""
|
||||||
# by documentation any game can use Archipelago locations/items -> always relevant
|
# by documentation any game can use Archipelago locations/items -> always relevant
|
||||||
relevant_games.add("Archipelago")
|
relevant_games.add("Archipelago")
|
||||||
|
|
||||||
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
|
||||||
needed_updates: typing.Set[str] = set()
|
needed_updates: typing.Set[str] = set()
|
||||||
for game in relevant_games:
|
for game in relevant_games:
|
||||||
remote_version: int = remote_datepackage_versions[game]
|
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
|
||||||
|
continue
|
||||||
|
|
||||||
if remote_version == 0: # custom datapackage for this game
|
remote_version: int = remote_date_package_versions.get(game, 0)
|
||||||
|
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
||||||
|
|
||||||
|
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
||||||
|
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
||||||
# no action required if local version is new enough
|
# no action required if local version is new enough
|
||||||
if remote_version > local_version:
|
if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
|
||||||
cache_version: int = cache_package.get(game, {}).get("version", 0)
|
or remote_checksum != local_checksum:
|
||||||
|
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
||||||
|
cache_version: int = cached_game.get("version", 0)
|
||||||
|
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
||||||
# download remote version if cache is not new enough
|
# download remote version if cache is not new enough
|
||||||
if remote_version > cache_version:
|
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
|
||||||
|
or remote_checksum != cache_checksum:
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
else:
|
else:
|
||||||
self.update_game(cache_package[game])
|
self.update_game(cached_game)
|
||||||
if needed_updates:
|
if needed_updates:
|
||||||
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
|
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
|
||||||
|
|
||||||
@@ -368,19 +462,36 @@ class CommonContext:
|
|||||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||||
self.location_names[location_id] = location_name
|
self.location_names[location_id] = location_name
|
||||||
|
|
||||||
def update_datapackage(self, data_package: dict):
|
def update_data_package(self, data_package: dict):
|
||||||
for game, gamedata in data_package["games"].items():
|
for game, game_data in data_package["games"].items():
|
||||||
self.update_game(gamedata)
|
self.update_game(game_data)
|
||||||
|
|
||||||
def consume_network_datapackage(self, data_package: dict):
|
def consume_network_data_package(self, data_package: dict):
|
||||||
self.update_datapackage(data_package)
|
self.update_data_package(data_package)
|
||||||
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||||
current_cache.update(data_package["games"])
|
current_cache.update(data_package["games"])
|
||||||
Utils.persistent_store("datapackage", "games", current_cache)
|
Utils.persistent_store("datapackage", "games", current_cache)
|
||||||
|
for game, game_data in data_package["games"].items():
|
||||||
|
Utils.store_data_package_for_checksum(game, game_data)
|
||||||
|
|
||||||
|
# data storage
|
||||||
|
|
||||||
|
def set_notify(self, *keys: str) -> None:
|
||||||
|
"""Subscribe to be notified of changes to selected data storage keys.
|
||||||
|
|
||||||
|
The values can be accessed via the "stored_data" attribute of this context, which is a dictionary mapping the
|
||||||
|
names of the data storage keys to the latest values received from the server.
|
||||||
|
"""
|
||||||
|
if new_keys := (set(keys) - self.stored_data_notification_keys):
|
||||||
|
self.stored_data_notification_keys.update(new_keys)
|
||||||
|
async_start(self.send_msgs([{"cmd": "Get",
|
||||||
|
"keys": list(new_keys)},
|
||||||
|
{"cmd": "SetNotify",
|
||||||
|
"keys": list(new_keys)}]))
|
||||||
|
|
||||||
# DeathLink hooks
|
# DeathLink hooks
|
||||||
|
|
||||||
def on_deathlink(self, data: dict):
|
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||||
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
||||||
self.last_death_link = max(data["time"], self.last_death_link)
|
self.last_death_link = max(data["time"], self.last_death_link)
|
||||||
text = data.get("cause", "")
|
text = data.get("cause", "")
|
||||||
@@ -411,10 +522,10 @@ class CommonContext:
|
|||||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||||
|
|
||||||
def gui_error(self, title: str, text: typing.Union[Exception, str]):
|
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
|
||||||
"""Displays an error messagebox"""
|
"""Displays an error messagebox"""
|
||||||
if not self.ui:
|
if not self.ui:
|
||||||
return
|
return None
|
||||||
title = title or "Error"
|
title = title or "Error"
|
||||||
from kvui import MessageBox
|
from kvui import MessageBox
|
||||||
if self._messagebox:
|
if self._messagebox:
|
||||||
@@ -431,6 +542,13 @@ class CommonContext:
|
|||||||
# display error
|
# display error
|
||||||
self._messagebox = MessageBox(title, text, error=True)
|
self._messagebox = MessageBox(title, text, error=True)
|
||||||
self._messagebox.open()
|
self._messagebox.open()
|
||||||
|
return self._messagebox
|
||||||
|
|
||||||
|
def handle_connection_loss(self, msg: str) -> None:
|
||||||
|
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
|
||||||
|
exc_info = sys.exc_info()
|
||||||
|
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||||
|
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
||||||
|
|
||||||
def run_gui(self):
|
def run_gui(self):
|
||||||
"""Import kivy UI system and start running it as self.ui_task."""
|
"""Import kivy UI system and start running it as self.ui_task."""
|
||||||
@@ -467,7 +585,7 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
|
|||||||
seconds_elapsed = 0
|
seconds_elapsed = 0
|
||||||
|
|
||||||
|
|
||||||
async def server_loop(ctx: CommonContext, address=None):
|
async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) -> None:
|
||||||
if ctx.server and ctx.server.socket:
|
if ctx.server and ctx.server.socket:
|
||||||
logger.error('Already connected')
|
logger.error('Already connected')
|
||||||
return
|
return
|
||||||
@@ -480,6 +598,11 @@ async def server_loop(ctx: CommonContext, address=None):
|
|||||||
logger.info('Please connect to an Archipelago server.')
|
logger.info('Please connect to an Archipelago server.')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
ctx.cancel_autoreconnect()
|
||||||
|
if ctx._messagebox_connection_loss:
|
||||||
|
ctx._messagebox_connection_loss.dismiss()
|
||||||
|
ctx._messagebox_connection_loss = None
|
||||||
|
|
||||||
address = f"ws://{address}" if "://" not in address \
|
address = f"ws://{address}" if "://" not in address \
|
||||||
else address.replace("archipelago://", "ws://")
|
else address.replace("archipelago://", "ws://")
|
||||||
|
|
||||||
@@ -490,40 +613,47 @@ async def server_loop(ctx: CommonContext, address=None):
|
|||||||
ctx.password = server_url.password
|
ctx.password = server_url.password
|
||||||
port = server_url.port or 38281
|
port = server_url.port or 38281
|
||||||
|
|
||||||
|
def reconnect_hint() -> str:
|
||||||
|
return ", type /connect to reconnect" if ctx.server_address else ""
|
||||||
|
|
||||||
logger.info(f'Connecting to Archipelago server at {address}')
|
logger.info(f'Connecting to Archipelago server at {address}')
|
||||||
try:
|
try:
|
||||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
|
||||||
|
ssl=get_ssl_context() if address.startswith("wss://") else None)
|
||||||
if ctx.ui is not None:
|
if ctx.ui is not None:
|
||||||
ctx.ui.update_address_bar(server_url.netloc)
|
ctx.ui.update_address_bar(server_url.netloc)
|
||||||
ctx.server = Endpoint(socket)
|
ctx.server = Endpoint(socket)
|
||||||
logger.info('Connected')
|
logger.info('Connected')
|
||||||
ctx.server_address = address
|
ctx.server_address = address
|
||||||
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
|
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
|
||||||
|
ctx.disconnected_intentionally = False
|
||||||
async for data in ctx.server.socket:
|
async for data in ctx.server.socket:
|
||||||
for msg in decode(data):
|
for msg in decode(data):
|
||||||
await process_server_cmd(ctx, msg)
|
await process_server_cmd(ctx, msg)
|
||||||
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
|
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}")
|
||||||
except ConnectionRefusedError as e:
|
except websockets.InvalidMessage:
|
||||||
msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
|
# probably encrypted
|
||||||
logger.exception(msg, extra={'compact_gui': True})
|
if address.startswith("ws://"):
|
||||||
ctx.gui_error(msg, e)
|
# try wss
|
||||||
except websockets.InvalidURI as e:
|
await server_loop(ctx, "ws" + address[1:])
|
||||||
msg = 'Failed to connect to the multiworld server (invalid URI)'
|
else:
|
||||||
logger.exception(msg, extra={'compact_gui': True})
|
ctx.handle_connection_loss(f"Lost connection to the multiworld server due to InvalidMessage"
|
||||||
ctx.gui_error(msg, e)
|
f"{reconnect_hint()}")
|
||||||
except OSError as e:
|
except ConnectionRefusedError:
|
||||||
msg = 'Failed to connect to the multiworld server'
|
ctx.handle_connection_loss("Connection refused by the server. "
|
||||||
logger.exception(msg, extra={'compact_gui': True})
|
"May not be running Archipelago on that address or port.")
|
||||||
ctx.gui_error(msg, e)
|
except websockets.InvalidURI:
|
||||||
except Exception as e:
|
ctx.handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
|
||||||
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
|
except OSError:
|
||||||
logger.exception(msg, extra={'compact_gui': True})
|
ctx.handle_connection_loss("Failed to connect to the multiworld server")
|
||||||
ctx.gui_error(msg, e)
|
except Exception:
|
||||||
|
ctx.handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
|
||||||
finally:
|
finally:
|
||||||
await ctx.connection_closed()
|
await ctx.connection_closed()
|
||||||
if ctx.server_address:
|
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally:
|
||||||
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
|
logger.info(f"... automatically reconnecting in {ctx.current_reconnect_delay} seconds")
|
||||||
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
assert ctx.autoreconnect_task is None
|
||||||
|
ctx.autoreconnect_task = asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
||||||
ctx.current_reconnect_delay *= 2
|
ctx.current_reconnect_delay *= 2
|
||||||
|
|
||||||
|
|
||||||
@@ -549,11 +679,16 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
logger.info('Room Information:')
|
logger.info('Room Information:')
|
||||||
logger.info('--------------------------------')
|
logger.info('--------------------------------')
|
||||||
version = args["version"]
|
version = args["version"]
|
||||||
ctx.server_version = tuple(version)
|
ctx.server_version = Version(*version)
|
||||||
version = ".".join(str(item) for item in version)
|
|
||||||
|
|
||||||
logger.info(f'Server protocol version: {version}')
|
if "generator_version" in args:
|
||||||
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
|
ctx.generator_version = Version(*args["generator_version"])
|
||||||
|
logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, '
|
||||||
|
f'generator version: {ctx.generator_version.as_simple_string()}, '
|
||||||
|
f'tags: {", ".join(args["tags"])}')
|
||||||
|
else:
|
||||||
|
logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, '
|
||||||
|
f'tags: {", ".join(args["tags"])}')
|
||||||
if args['password']:
|
if args['password']:
|
||||||
logger.info('Password required')
|
logger.info('Password required')
|
||||||
ctx.update_permissions(args.get("permissions", {}))
|
ctx.update_permissions(args.get("permissions", {}))
|
||||||
@@ -578,14 +713,16 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
current_team = network_player.team
|
current_team = network_player.team
|
||||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||||
|
|
||||||
# update datapackage
|
# update data package
|
||||||
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
|
data_package_versions = args.get("datapackage_versions", {})
|
||||||
|
data_package_checksums = args.get("datapackage_checksums", {})
|
||||||
|
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
|
||||||
|
|
||||||
await ctx.server_auth(args['password'])
|
await ctx.server_auth(args['password'])
|
||||||
|
|
||||||
elif cmd == 'DataPackage':
|
elif cmd == 'DataPackage':
|
||||||
logger.info("Got new ID/Name DataPackage")
|
logger.info("Got new ID/Name DataPackage")
|
||||||
ctx.consume_network_datapackage(args['data'])
|
ctx.consume_network_data_package(args['data'])
|
||||||
|
|
||||||
elif cmd == 'ConnectionRefused':
|
elif cmd == 'ConnectionRefused':
|
||||||
errors = args["errors"]
|
errors = args["errors"]
|
||||||
@@ -613,6 +750,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.slot = args["slot"]
|
ctx.slot = args["slot"]
|
||||||
# int keys get lost in JSON transfer
|
# int keys get lost in JSON transfer
|
||||||
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
|
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
|
||||||
|
ctx.hint_points = args.get("hint_points", 0)
|
||||||
ctx.consume_players_package(args["players"])
|
ctx.consume_players_package(args["players"])
|
||||||
msgs = []
|
msgs = []
|
||||||
if ctx.locations_checked:
|
if ctx.locations_checked:
|
||||||
@@ -621,6 +759,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
if ctx.locations_scouted:
|
if ctx.locations_scouted:
|
||||||
msgs.append({"cmd": "LocationScouts",
|
msgs.append({"cmd": "LocationScouts",
|
||||||
"locations": list(ctx.locations_scouted)})
|
"locations": list(ctx.locations_scouted)})
|
||||||
|
if ctx.stored_data_notification_keys:
|
||||||
|
msgs.append({"cmd": "Get",
|
||||||
|
"keys": list(ctx.stored_data_notification_keys)})
|
||||||
|
msgs.append({"cmd": "SetNotify",
|
||||||
|
"keys": list(ctx.stored_data_notification_keys)})
|
||||||
if msgs:
|
if msgs:
|
||||||
await ctx.send_msgs(msgs)
|
await ctx.send_msgs(msgs)
|
||||||
if ctx.finished_game:
|
if ctx.finished_game:
|
||||||
@@ -632,6 +775,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
# when /missing is used for the client side view of what is missing.
|
# when /missing is used for the client side view of what is missing.
|
||||||
ctx.missing_locations = set(args["missing_locations"])
|
ctx.missing_locations = set(args["missing_locations"])
|
||||||
ctx.checked_locations = set(args["checked_locations"])
|
ctx.checked_locations = set(args["checked_locations"])
|
||||||
|
ctx.server_locations = ctx.missing_locations | ctx. checked_locations
|
||||||
|
|
||||||
|
server_url = urllib.parse.urlparse(ctx.server_address)
|
||||||
|
Utils.persistent_store("client", "last_server_address", server_url.netloc)
|
||||||
|
|
||||||
elif cmd == 'ReceivedItems':
|
elif cmd == 'ReceivedItems':
|
||||||
start_index = args["index"]
|
start_index = args["index"]
|
||||||
@@ -680,7 +827,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
|
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
|
||||||
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
|
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
|
||||||
ctx.on_deathlink(args["data"])
|
ctx.on_deathlink(args["data"])
|
||||||
|
|
||||||
|
elif cmd == "Retrieved":
|
||||||
|
ctx.stored_data.update(args["keys"])
|
||||||
|
|
||||||
elif cmd == "SetReply":
|
elif cmd == "SetReply":
|
||||||
|
ctx.stored_data[args["key"]] = args["value"]
|
||||||
if args["key"] == "EnergyLink":
|
if args["key"] == "EnergyLink":
|
||||||
ctx.current_energy_link_value = args["value"]
|
ctx.current_energy_link_value = args["value"]
|
||||||
if ctx.ui:
|
if ctx.ui:
|
||||||
@@ -711,7 +863,7 @@ async def console_loop(ctx: CommonContext):
|
|||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
|
|
||||||
|
|
||||||
def get_base_parser(description=None):
|
def get_base_parser(description: typing.Optional[str] = None):
|
||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser(description=description)
|
parser = argparse.ArgumentParser(description=description)
|
||||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||||
@@ -721,13 +873,13 @@ def get_base_parser(description=None):
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
def run_as_textclient():
|
||||||
# Text Mode to use !hint and such with games that have no text entry
|
|
||||||
|
|
||||||
class TextContext(CommonContext):
|
class TextContext(CommonContext):
|
||||||
tags = {"AP", "IgnoreGame", "TextOnly"}
|
# Text Mode to use !hint and such with games that have no text entry
|
||||||
|
tags = {"AP", "TextOnly"}
|
||||||
game = "" # empty matches any game since 0.3.2
|
game = "" # empty matches any game since 0.3.2
|
||||||
items_handling = 0b111 # receive all items for /received
|
items_handling = 0b111 # receive all items for /received
|
||||||
|
want_slot_data = False # Can't use game specific slot_data
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
async def server_auth(self, password_requested: bool = False):
|
||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
@@ -739,11 +891,13 @@ if __name__ == '__main__':
|
|||||||
if cmd == "Connected":
|
if cmd == "Connected":
|
||||||
self.game = self.slot_info[self.slot].game
|
self.game = self.slot_info[self.slot].game
|
||||||
|
|
||||||
|
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||||
|
self.game = ""
|
||||||
|
await super().disconnect(allow_autoreconnect)
|
||||||
|
|
||||||
async def main(args):
|
async def main(args):
|
||||||
ctx = TextContext(args.connect, args.password)
|
ctx = TextContext(args.connect, args.password)
|
||||||
ctx.auth = args.name
|
ctx.auth = args.name
|
||||||
ctx.server_address = args.connect
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||||
|
|
||||||
if gui_enabled:
|
if gui_enabled:
|
||||||
@@ -753,7 +907,6 @@ if __name__ == '__main__':
|
|||||||
await ctx.exit_event.wait()
|
await ctx.exit_event.wait()
|
||||||
await ctx.shutdown()
|
await ctx.shutdown()
|
||||||
|
|
||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
||||||
@@ -773,3 +926,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run_as_textclient()
|
||||||
|
|||||||
66
FF1Client.py
66
FF1Client.py
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from asyncio import StreamReader, StreamWriter
|
from asyncio import StreamReader, StreamWriter
|
||||||
@@ -6,14 +7,15 @@ from typing import List
|
|||||||
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
|
from Utils import async_start
|
||||||
|
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||||
get_base_parser
|
get_base_parser
|
||||||
|
|
||||||
SYSTEM_MESSAGE_ID = 0
|
SYSTEM_MESSAGE_ID = 0
|
||||||
|
|
||||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart ff1_connector.lua"
|
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 ff1_connector.lua is running"
|
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 ff1_connector.lua"
|
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
|
||||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||||
@@ -31,7 +33,7 @@ class FF1CommandProcessor(ClientCommandProcessor):
|
|||||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
logger.info(f"NES Status: {self.ctx.nes_status}")
|
||||||
|
|
||||||
def _cmd_toggle_msgs(self):
|
def _cmd_toggle_msgs(self):
|
||||||
"""Toggle displaying messages in bizhawk"""
|
"""Toggle displaying messages in EmuHawk"""
|
||||||
global DISPLAY_MSGS
|
global DISPLAY_MSGS
|
||||||
DISPLAY_MSGS = not DISPLAY_MSGS
|
DISPLAY_MSGS = not DISPLAY_MSGS
|
||||||
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
||||||
@@ -64,41 +66,37 @@ class FF1Context(CommonContext):
|
|||||||
|
|
||||||
def _set_message(self, msg: str, msg_id: int):
|
def _set_message(self, msg: str, msg_id: int):
|
||||||
if DISPLAY_MSGS:
|
if DISPLAY_MSGS:
|
||||||
self.messages[(time.time(), msg_id)] = msg
|
self.messages[time.time(), msg_id] = msg
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd == 'Connected':
|
if cmd == 'Connected':
|
||||||
asyncio.create_task(parse_locations(self.locations_array, self, True))
|
async_start(parse_locations(self.locations_array, self, True))
|
||||||
elif cmd == 'Print':
|
elif cmd == 'Print':
|
||||||
msg = args['text']
|
msg = args['text']
|
||||||
if ': !' not in msg:
|
if ': !' not in msg:
|
||||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||||
elif cmd == "ReceivedItems":
|
|
||||||
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
def on_print_json(self, args: dict):
|
||||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
if self.ui:
|
||||||
elif cmd == 'PrintJSON':
|
self.ui.print_json(copy.deepcopy(args["data"]))
|
||||||
print_type = args['type']
|
else:
|
||||||
item = args['item']
|
text = self.jsontotextparser(copy.deepcopy(args["data"]))
|
||||||
receiving_player_id = args['receiving']
|
logger.info(text)
|
||||||
receiving_player_name = self.player_names[receiving_player_id]
|
relevant = args.get("type", None) in {"Hint", "ItemSend"}
|
||||||
sending_player_id = item.player
|
if relevant:
|
||||||
sending_player_name = self.player_names[item.player]
|
item = args["item"]
|
||||||
if print_type == 'Hint':
|
# goes to this world
|
||||||
msg = f"Hint: Your {self.item_names[item.item]} is at" \
|
if self.slot_concerns_self(args["receiving"]):
|
||||||
f" {self.player_names[item.player]}'s {self.location_names[item.location]}"
|
relevant = True
|
||||||
self._set_message(msg, item.item)
|
# found in this world
|
||||||
elif print_type == 'ItemSend' and receiving_player_id != self.slot:
|
elif self.slot_concerns_self(item.player):
|
||||||
if sending_player_id == self.slot:
|
relevant = True
|
||||||
if receiving_player_id == self.slot:
|
# not related
|
||||||
msg = f"You found your own {self.item_names[item.item]}"
|
else:
|
||||||
else:
|
relevant = False
|
||||||
msg = f"You sent {self.item_names[item.item]} to {receiving_player_name}"
|
if relevant:
|
||||||
else:
|
item = args["item"]
|
||||||
if receiving_player_id == sending_player_id:
|
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
|
||||||
msg = f"{sending_player_name} found their {self.item_names[item.item]}"
|
|
||||||
else:
|
|
||||||
msg = f"{sending_player_name} sent {self.item_names[item.item]} to " \
|
|
||||||
f"{receiving_player_name}"
|
|
||||||
self._set_message(msg, item.item)
|
self._set_message(msg, item.item)
|
||||||
|
|
||||||
def run_gui(self):
|
def run_gui(self):
|
||||||
@@ -183,7 +181,7 @@ async def nes_sync_task(ctx: FF1Context):
|
|||||||
# print(data_decoded)
|
# print(data_decoded)
|
||||||
if ctx.game is not None and 'locations' in data_decoded:
|
if ctx.game is not None and 'locations' in data_decoded:
|
||||||
# Not just a keep alive ping, parse
|
# Not just a keep alive ping, parse
|
||||||
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
|
async_start(parse_locations(data_decoded['locations'], ctx, False))
|
||||||
if not ctx.auth:
|
if not ctx.auth:
|
||||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||||
if ctx.auth == '':
|
if ctx.auth == '':
|
||||||
|
|||||||
@@ -1,433 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
import string
|
|
||||||
import copy
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
import random
|
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
import factorio_rcon
|
from worlds.factorio.Client import check_stdin, launch
|
||||||
import colorama
|
|
||||||
import asyncio
|
|
||||||
from queue import Queue
|
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||||
|
check_stdin()
|
||||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
|
launch()
|
||||||
from MultiServer import mark_raw
|
|
||||||
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
|
||||||
|
|
||||||
from worlds.factorio import Factorio
|
|
||||||
|
|
||||||
|
|
||||||
class FactorioCommandProcessor(ClientCommandProcessor):
|
|
||||||
ctx: FactorioContext
|
|
||||||
|
|
||||||
@mark_raw
|
|
||||||
def _cmd_factorio(self, text: str) -> bool:
|
|
||||||
"""Send the following command to the bound Factorio Server."""
|
|
||||||
if self.ctx.rcon_client:
|
|
||||||
# TODO: Print the command non-silently only for race seeds, or otherwise block anything but /factorio /save in race seeds.
|
|
||||||
self.ctx.print_to_game(f"/factorio {text}")
|
|
||||||
result = self.ctx.rcon_client.send_command(text)
|
|
||||||
if result:
|
|
||||||
self.output(result)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _cmd_resync(self):
|
|
||||||
"""Manually trigger a resync."""
|
|
||||||
self.ctx.awaiting_bridge = True
|
|
||||||
|
|
||||||
|
|
||||||
class FactorioContext(CommonContext):
|
|
||||||
command_processor = FactorioCommandProcessor
|
|
||||||
game = "Factorio"
|
|
||||||
items_handling = 0b111 # full remote
|
|
||||||
|
|
||||||
# updated by spinup server
|
|
||||||
mod_version: Utils.Version = Utils.Version(0, 0, 0)
|
|
||||||
|
|
||||||
def __init__(self, server_address, password):
|
|
||||||
super(FactorioContext, self).__init__(server_address, password)
|
|
||||||
self.send_index: int = 0
|
|
||||||
self.rcon_client = None
|
|
||||||
self.awaiting_bridge = False
|
|
||||||
self.write_data_path = None
|
|
||||||
self.death_link_tick: int = 0 # last send death link on Factorio layer
|
|
||||||
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
|
||||||
self.energy_link_increment = 0
|
|
||||||
self.last_deplete = 0
|
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
|
||||||
if password_requested and not self.password:
|
|
||||||
await super(FactorioContext, self).server_auth(password_requested)
|
|
||||||
|
|
||||||
if self.rcon_client:
|
|
||||||
await get_info(self, self.rcon_client) # retrieve current auth code
|
|
||||||
else:
|
|
||||||
raise Exception("Cannot connect to a server with unknown own identity, "
|
|
||||||
"bridge to Factorio first.")
|
|
||||||
|
|
||||||
await self.send_connect()
|
|
||||||
|
|
||||||
def on_print(self, args: dict):
|
|
||||||
super(FactorioContext, self).on_print(args)
|
|
||||||
if self.rcon_client:
|
|
||||||
self.print_to_game(args['text'])
|
|
||||||
|
|
||||||
def on_print_json(self, args: dict):
|
|
||||||
if self.rcon_client:
|
|
||||||
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
|
|
||||||
self.print_to_game(text)
|
|
||||||
super(FactorioContext, self).on_print_json(args)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def savegame_name(self) -> str:
|
|
||||||
return f"AP_{self.seed_name}_{self.auth}_Save.zip"
|
|
||||||
|
|
||||||
def print_to_game(self, text):
|
|
||||||
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
|
||||||
f"{text}")
|
|
||||||
|
|
||||||
def on_deathlink(self, data: dict):
|
|
||||||
if self.rcon_client:
|
|
||||||
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
|
|
||||||
super(FactorioContext, self).on_deathlink(data)
|
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
|
||||||
if cmd in {"Connected", "RoomUpdate"}:
|
|
||||||
# catch up sync anything that is already cleared.
|
|
||||||
if "checked_locations" in args and args["checked_locations"]:
|
|
||||||
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
|
||||||
item_name in args["checked_locations"]})
|
|
||||||
if cmd == "Connected" and self.energy_link_increment:
|
|
||||||
asyncio.create_task(self.send_msgs([{
|
|
||||||
"cmd": "SetNotify", "keys": ["EnergyLink"]
|
|
||||||
}]))
|
|
||||||
elif cmd == "SetReply":
|
|
||||||
if args["key"] == "EnergyLink":
|
|
||||||
if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete:
|
|
||||||
# it's our deplete request
|
|
||||||
gained = int(args["original_value"] - args["value"])
|
|
||||||
gained_text = Utils.format_SI_prefix(gained) + "J"
|
|
||||||
if gained:
|
|
||||||
logger.debug(f"EnergyLink: Received {gained_text}. "
|
|
||||||
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
|
||||||
self.rcon_client.send_command(f"/ap-energylink {gained}")
|
|
||||||
|
|
||||||
def run_gui(self):
|
|
||||||
from kvui import GameManager
|
|
||||||
|
|
||||||
class FactorioManager(GameManager):
|
|
||||||
logging_pairs = [
|
|
||||||
("Client", "Archipelago"),
|
|
||||||
("FactorioServer", "Factorio Server Log"),
|
|
||||||
("FactorioWatcher", "Bridge Data Log"),
|
|
||||||
]
|
|
||||||
base_title = "Archipelago Factorio Client"
|
|
||||||
|
|
||||||
self.ui = FactorioManager(self)
|
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
|
||||||
|
|
||||||
|
|
||||||
async def game_watcher(ctx: FactorioContext):
|
|
||||||
bridge_logger = logging.getLogger("FactorioWatcher")
|
|
||||||
from worlds.factorio.Technologies import lookup_id_to_name
|
|
||||||
next_bridge = time.perf_counter() + 1
|
|
||||||
try:
|
|
||||||
while not ctx.exit_event.is_set():
|
|
||||||
# TODO: restore on-demand refresh
|
|
||||||
if ctx.rcon_client and time.perf_counter() > next_bridge:
|
|
||||||
next_bridge = time.perf_counter() + 1
|
|
||||||
ctx.awaiting_bridge = False
|
|
||||||
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
|
|
||||||
if not ctx.auth:
|
|
||||||
pass # auth failed, wait for new attempt
|
|
||||||
elif data["slot_name"] != ctx.auth:
|
|
||||||
bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
|
|
||||||
elif data["seed_name"] != ctx.seed_name:
|
|
||||||
bridge_logger.warning(
|
|
||||||
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
|
|
||||||
else:
|
|
||||||
data = data["info"]
|
|
||||||
research_data = data["research_done"]
|
|
||||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
|
||||||
victory = data["victory"]
|
|
||||||
await ctx.update_death_link(data["death_link"])
|
|
||||||
|
|
||||||
if not ctx.finished_game and victory:
|
|
||||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
|
||||||
ctx.finished_game = True
|
|
||||||
|
|
||||||
if ctx.locations_checked != research_data:
|
|
||||||
bridge_logger.debug(
|
|
||||||
f"New researches done: "
|
|
||||||
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
|
|
||||||
ctx.locations_checked = research_data
|
|
||||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
|
||||||
death_link_tick = data.get("death_link_tick", 0)
|
|
||||||
if death_link_tick != ctx.death_link_tick:
|
|
||||||
ctx.death_link_tick = death_link_tick
|
|
||||||
if "DeathLink" in ctx.tags:
|
|
||||||
asyncio.create_task(ctx.send_death())
|
|
||||||
if ctx.energy_link_increment:
|
|
||||||
in_world_bridges = data["energy_bridges"]
|
|
||||||
if in_world_bridges:
|
|
||||||
in_world_energy = data["energy"]
|
|
||||||
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
|
|
||||||
# attempt to refill
|
|
||||||
ctx.last_deplete = time.time()
|
|
||||||
asyncio.create_task(ctx.send_msgs([{
|
|
||||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
|
||||||
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
|
|
||||||
{"operation": "max", "value": 0}],
|
|
||||||
"last_deplete": ctx.last_deplete
|
|
||||||
}]))
|
|
||||||
# Above Capacity - (len(Bridges) * ENERGY_INCREMENT)
|
|
||||||
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
|
|
||||||
ctx.energy_link_increment*in_world_bridges:
|
|
||||||
value = ctx.energy_link_increment * in_world_bridges
|
|
||||||
asyncio.create_task(ctx.send_msgs([{
|
|
||||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
|
||||||
[{"operation": "add", "value": value}]
|
|
||||||
}]))
|
|
||||||
ctx.rcon_client.send_command(
|
|
||||||
f"/ap-energylink -{value}")
|
|
||||||
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
|
|
||||||
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.exception(e)
|
|
||||||
logging.error("Aborted Factorio Server Bridge")
|
|
||||||
|
|
||||||
|
|
||||||
def stream_factorio_output(pipe, queue, process):
|
|
||||||
def queuer():
|
|
||||||
while process.poll() is None:
|
|
||||||
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()
|
|
||||||
return thread
|
|
||||||
|
|
||||||
|
|
||||||
async def factorio_server_watcher(ctx: FactorioContext):
|
|
||||||
savegame_name = os.path.abspath(ctx.savegame_name)
|
|
||||||
if not os.path.exists(savegame_name):
|
|
||||||
logger.info(f"Creating savegame {savegame_name}")
|
|
||||||
subprocess.run((
|
|
||||||
executable, "--create", savegame_name, "--preset", "archipelago"
|
|
||||||
))
|
|
||||||
factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name,
|
|
||||||
*(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, factorio_process)
|
|
||||||
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
|
||||||
try:
|
|
||||||
while not ctx.exit_event.is_set():
|
|
||||||
if factorio_process.poll():
|
|
||||||
factorio_server_logger.info("Factorio server has exited.")
|
|
||||||
ctx.exit_event.set()
|
|
||||||
|
|
||||||
while not factorio_queue.empty():
|
|
||||||
msg = factorio_queue.get()
|
|
||||||
factorio_queue.task_done()
|
|
||||||
|
|
||||||
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
|
||||||
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
|
||||||
if not ctx.server:
|
|
||||||
logger.info("Established bridge to Factorio Server. "
|
|
||||||
"Ready to connect to Archipelago via /connect")
|
|
||||||
|
|
||||||
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
|
|
||||||
ctx.awaiting_bridge = True
|
|
||||||
factorio_server_logger.debug(msg)
|
|
||||||
else:
|
|
||||||
factorio_server_logger.info(msg)
|
|
||||||
if ctx.rcon_client:
|
|
||||||
commands = {}
|
|
||||||
while ctx.send_index < len(ctx.items_received):
|
|
||||||
transfer_item: NetworkItem = ctx.items_received[ctx.send_index]
|
|
||||||
item_id = transfer_item.item
|
|
||||||
player_name = ctx.player_names[transfer_item.player]
|
|
||||||
if item_id not in Factorio.item_id_to_name:
|
|
||||||
factorio_server_logger.error(f"Cannot send unknown item ID: {item_id}")
|
|
||||||
else:
|
|
||||||
item_name = Factorio.item_id_to_name[item_id]
|
|
||||||
factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.")
|
|
||||||
commands[ctx.send_index] = f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}'
|
|
||||||
ctx.send_index += 1
|
|
||||||
if commands:
|
|
||||||
ctx.rcon_client.send_commands(commands)
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.exception(e)
|
|
||||||
logging.error("Aborted Factorio Server Bridge")
|
|
||||||
ctx.rcon_client = None
|
|
||||||
ctx.exit_event.set()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
factorio_process.terminate()
|
|
||||||
factorio_process.wait(5)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
|
|
||||||
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
|
|
||||||
ctx.auth = info["slot_name"]
|
|
||||||
ctx.seed_name = info["seed_name"]
|
|
||||||
# 0.2.0 addition, not present earlier
|
|
||||||
death_link = bool(info.get("death_link", False))
|
|
||||||
ctx.energy_link_increment = info.get("energy_link", 0)
|
|
||||||
logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}")
|
|
||||||
if ctx.energy_link_increment and ctx.ui:
|
|
||||||
ctx.ui.enable_energy_link()
|
|
||||||
await ctx.update_death_link(death_link)
|
|
||||||
|
|
||||||
|
|
||||||
async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
|
||||||
savegame_name = os.path.abspath("Archipelago.zip")
|
|
||||||
if not os.path.exists(savegame_name):
|
|
||||||
logger.info(f"Creating savegame {savegame_name}")
|
|
||||||
subprocess.run((
|
|
||||||
executable, "--create", savegame_name
|
|
||||||
))
|
|
||||||
factorio_process = subprocess.Popen(
|
|
||||||
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
encoding="utf-8")
|
|
||||||
factorio_server_logger.info("Started Information Exchange Factorio Server")
|
|
||||||
factorio_queue = Queue()
|
|
||||||
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
|
|
||||||
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
|
||||||
rcon_client = None
|
|
||||||
try:
|
|
||||||
while not ctx.auth:
|
|
||||||
while not factorio_queue.empty():
|
|
||||||
msg = factorio_queue.get()
|
|
||||||
factorio_server_logger.info(msg)
|
|
||||||
if "Loading mod AP-" in msg and msg.endswith("(data.lua)"):
|
|
||||||
parts = msg.split()
|
|
||||||
ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split(".")))
|
|
||||||
elif "Write data path: " in msg:
|
|
||||||
ctx.write_data_path = Utils.get_text_between(msg, "Write data path: ", " [")
|
|
||||||
if "AppData" in ctx.write_data_path:
|
|
||||||
logger.warning("It appears your mods are loaded from Appdata, "
|
|
||||||
"this can lead to problems with multiple Factorio instances. "
|
|
||||||
"If this is the case, you will get a file locked error running Factorio.")
|
|
||||||
if not rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
|
||||||
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
|
||||||
if ctx.mod_version == ctx.__class__.mod_version:
|
|
||||||
raise Exception("No Archipelago mod was loaded. Aborting.")
|
|
||||||
await get_info(ctx, rcon_client)
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(e, extra={"compact_gui": True})
|
|
||||||
msg = "Aborted Factorio Server Bridge"
|
|
||||||
logger.error(msg)
|
|
||||||
ctx.gui_error(msg, e)
|
|
||||||
ctx.exit_event.set()
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}")
|
|
||||||
return True
|
|
||||||
finally:
|
|
||||||
factorio_process.terminate()
|
|
||||||
factorio_process.wait(5)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def main(args):
|
|
||||||
ctx = FactorioContext(args.connect, args.password)
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
|
||||||
|
|
||||||
if gui_enabled:
|
|
||||||
ctx.run_gui()
|
|
||||||
ctx.run_cli()
|
|
||||||
|
|
||||||
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
|
|
||||||
successful_launch = await factorio_server_task
|
|
||||||
if successful_launch:
|
|
||||||
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
|
|
||||||
progression_watcher = asyncio.create_task(
|
|
||||||
game_watcher(ctx), name="FactorioProgressionWatcher")
|
|
||||||
|
|
||||||
await ctx.exit_event.wait()
|
|
||||||
ctx.server_address = None
|
|
||||||
|
|
||||||
await progression_watcher
|
|
||||||
await factorio_server_task
|
|
||||||
|
|
||||||
await ctx.shutdown()
|
|
||||||
|
|
||||||
|
|
||||||
class FactorioJSONtoTextParser(JSONtoTextParser):
|
|
||||||
def _handle_color(self, node: JSONMessagePart):
|
|
||||||
colors = node["color"].split(";")
|
|
||||||
for color in colors:
|
|
||||||
if color in self.color_codes:
|
|
||||||
node["text"] = f"[color=#{self.color_codes[color]}]{node['text']}[/color]"
|
|
||||||
return self._handle_text(node)
|
|
||||||
return self._handle_text(node)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
parser = get_base_parser(description="Optional arguments to FactorioClient follow. "
|
|
||||||
"Remaining arguments get passed into bound Factorio instance."
|
|
||||||
"Refer to Factorio --help for those.")
|
|
||||||
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
|
|
||||||
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
|
|
||||||
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
|
|
||||||
|
|
||||||
args, rest = parser.parse_known_args()
|
|
||||||
colorama.init()
|
|
||||||
rcon_port = args.rcon_port
|
|
||||||
rcon_password = args.rcon_password if args.rcon_password else ''.join(
|
|
||||||
random.choice(string.ascii_letters) for x in range(32))
|
|
||||||
|
|
||||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
|
||||||
options = Utils.get_options()
|
|
||||||
executable = options["factorio_options"]["executable"]
|
|
||||||
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
|
|
||||||
if server_settings:
|
|
||||||
server_settings = os.path.abspath(server_settings)
|
|
||||||
|
|
||||||
if not os.path.exists(os.path.dirname(executable)):
|
|
||||||
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
|
||||||
if os.path.isdir(executable): # user entered a path to a directory, let's find the executable therein
|
|
||||||
executable = os.path.join(executable, "factorio")
|
|
||||||
if not os.path.isfile(executable):
|
|
||||||
if os.path.isfile(executable + ".exe"):
|
|
||||||
executable = executable + ".exe"
|
|
||||||
else:
|
|
||||||
raise FileNotFoundError(f"Path {executable} is not an executable file.")
|
|
||||||
|
|
||||||
if server_settings and os.path.isfile(server_settings):
|
|
||||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, "--server-settings", server_settings, *rest)
|
|
||||||
else:
|
|
||||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
|
||||||
|
|
||||||
asyncio.run(main(args))
|
|
||||||
colorama.deinit()
|
|
||||||
|
|||||||
550
Fill.py
550
Fill.py
@@ -1,12 +1,12 @@
|
|||||||
import logging
|
|
||||||
import typing
|
|
||||||
import collections
|
import collections
|
||||||
import itertools
|
import itertools
|
||||||
|
import logging
|
||||||
|
import typing
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
|
|
||||||
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
||||||
|
|
||||||
from worlds.AutoWorld import call_all
|
from worlds.AutoWorld import call_all
|
||||||
|
from worlds.generic.Rules import add_item_rule
|
||||||
|
|
||||||
|
|
||||||
class FillError(RuntimeError):
|
class FillError(RuntimeError):
|
||||||
@@ -22,13 +22,28 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
|||||||
|
|
||||||
|
|
||||||
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||||
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None:
|
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
||||||
|
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
||||||
|
allow_partial: bool = False, allow_excluded: bool = False) -> None:
|
||||||
|
"""
|
||||||
|
:param world: Multiworld to be filled.
|
||||||
|
:param base_state: State assumed before fill.
|
||||||
|
:param locations: Locations to be filled with item_pool
|
||||||
|
:param item_pool: Items to fill into the locations
|
||||||
|
:param single_player_placement: if true, can speed up placement if everything belongs to a single player
|
||||||
|
:param lock: locations are set to locked as they are filled
|
||||||
|
:param swap: if true, swaps of already place items are done in the event of a dead end
|
||||||
|
:param on_place: callback that is called when a placement happens
|
||||||
|
:param allow_partial: only place what is possible. Remaining items will be in the item_pool list.
|
||||||
|
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
|
||||||
|
"""
|
||||||
unplaced_items: typing.List[Item] = []
|
unplaced_items: typing.List[Item] = []
|
||||||
placements: typing.List[Location] = []
|
placements: typing.List[Location] = []
|
||||||
|
cleanup_required = False
|
||||||
|
|
||||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
|
||||||
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
|
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
|
||||||
for item in itempool:
|
for item in item_pool:
|
||||||
reachable_items.setdefault(item.player, deque()).append(item)
|
reachable_items.setdefault(item.player, deque()).append(item)
|
||||||
|
|
||||||
while any(reachable_items.values()) and locations:
|
while any(reachable_items.values()) and locations:
|
||||||
@@ -36,9 +51,9 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
|||||||
items_to_place = [items.pop()
|
items_to_place = [items.pop()
|
||||||
for items in reachable_items.values() if items]
|
for items in reachable_items.values() if items]
|
||||||
for item in items_to_place:
|
for item in items_to_place:
|
||||||
itempool.remove(item)
|
item_pool.remove(item)
|
||||||
maximum_exploration_state = sweep_from_pool(
|
maximum_exploration_state = sweep_from_pool(
|
||||||
base_state, itempool + unplaced_items)
|
base_state, item_pool + unplaced_items)
|
||||||
|
|
||||||
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
|
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
|
||||||
|
|
||||||
@@ -69,62 +84,98 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# we filled all reachable spots.
|
# we filled all reachable spots.
|
||||||
# try swapping this item with previously placed items
|
if swap:
|
||||||
for (i, location) in enumerate(placements):
|
# try swapping this item with previously placed items in a safe way then in an unsafe way
|
||||||
placed_item = location.item
|
swap_attempts = ((i, location, unsafe)
|
||||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
for unsafe in (False, True)
|
||||||
# number of times we will swap an individual item to prevent this
|
for i, location in enumerate(placements))
|
||||||
swap_count = swapped_items[placed_item.player,
|
for (i, location, unsafe) in swap_attempts:
|
||||||
placed_item.name]
|
placed_item = location.item
|
||||||
if swap_count > 1:
|
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||||
|
# number of times we will swap an individual item to prevent this
|
||||||
|
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
|
||||||
|
if swap_count > 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
location.item = None
|
||||||
|
placed_item.location = None
|
||||||
|
swap_state = sweep_from_pool(base_state, [placed_item] if unsafe else [])
|
||||||
|
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
|
||||||
|
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
|
||||||
|
# to clean that up later, so there is a chance generation fails.
|
||||||
|
if (not single_player_placement or location.player == item_to_place.player) \
|
||||||
|
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
||||||
|
|
||||||
|
# Verify placing this item won't reduce available locations, which would be a useless swap.
|
||||||
|
prev_state = swap_state.copy()
|
||||||
|
prev_loc_count = len(
|
||||||
|
world.get_reachable_locations(prev_state))
|
||||||
|
|
||||||
|
swap_state.collect(item_to_place, True)
|
||||||
|
new_loc_count = len(
|
||||||
|
world.get_reachable_locations(swap_state))
|
||||||
|
|
||||||
|
if new_loc_count >= prev_loc_count:
|
||||||
|
# Add this item to the existing placement, and
|
||||||
|
# add the old item to the back of the queue
|
||||||
|
spot_to_fill = placements.pop(i)
|
||||||
|
|
||||||
|
swap_count += 1
|
||||||
|
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
|
||||||
|
|
||||||
|
reachable_items[placed_item.player].appendleft(
|
||||||
|
placed_item)
|
||||||
|
item_pool.append(placed_item)
|
||||||
|
|
||||||
|
# cleanup at the end to hopefully get better errors
|
||||||
|
cleanup_required = True
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
# Item can't be placed here, restore original item
|
||||||
|
location.item = placed_item
|
||||||
|
placed_item.location = location
|
||||||
|
|
||||||
|
if spot_to_fill is None:
|
||||||
|
# Can't place this item, move on to the next
|
||||||
|
unplaced_items.append(item_to_place)
|
||||||
continue
|
continue
|
||||||
|
else:
|
||||||
location.item = None
|
|
||||||
placed_item.location = None
|
|
||||||
swap_state = sweep_from_pool(base_state)
|
|
||||||
if (not single_player_placement or location.player == item_to_place.player) \
|
|
||||||
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
|
||||||
|
|
||||||
# Verify that placing this item won't reduce available locations
|
|
||||||
prev_state = swap_state.copy()
|
|
||||||
prev_state.collect(placed_item)
|
|
||||||
prev_loc_count = len(
|
|
||||||
world.get_reachable_locations(prev_state))
|
|
||||||
|
|
||||||
swap_state.collect(item_to_place, True)
|
|
||||||
new_loc_count = len(
|
|
||||||
world.get_reachable_locations(swap_state))
|
|
||||||
|
|
||||||
if new_loc_count >= prev_loc_count:
|
|
||||||
# Add this item to the existing placement, and
|
|
||||||
# add the old item to the back of the queue
|
|
||||||
spot_to_fill = placements.pop(i)
|
|
||||||
|
|
||||||
swap_count += 1
|
|
||||||
swapped_items[placed_item.player,
|
|
||||||
placed_item.name] = swap_count
|
|
||||||
|
|
||||||
reachable_items[placed_item.player].appendleft(
|
|
||||||
placed_item)
|
|
||||||
itempool.append(placed_item)
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
# Item can't be placed here, restore original item
|
|
||||||
location.item = placed_item
|
|
||||||
placed_item.location = location
|
|
||||||
|
|
||||||
if spot_to_fill is None:
|
|
||||||
# Can't place this item, move on to the next
|
|
||||||
unplaced_items.append(item_to_place)
|
unplaced_items.append(item_to_place)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
world.push_item(spot_to_fill, item_to_place, False)
|
world.push_item(spot_to_fill, item_to_place, False)
|
||||||
spot_to_fill.locked = lock
|
spot_to_fill.locked = lock
|
||||||
placements.append(spot_to_fill)
|
placements.append(spot_to_fill)
|
||||||
spot_to_fill.event = item_to_place.advancement
|
spot_to_fill.event = item_to_place.advancement
|
||||||
|
if on_place:
|
||||||
|
on_place(spot_to_fill)
|
||||||
|
|
||||||
if len(unplaced_items) > 0 and len(locations) > 0:
|
if cleanup_required:
|
||||||
|
# validate all placements and remove invalid ones
|
||||||
|
for placement in placements:
|
||||||
|
state = sweep_from_pool(base_state, [])
|
||||||
|
if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state):
|
||||||
|
placement.item.location = None
|
||||||
|
unplaced_items.append(placement.item)
|
||||||
|
placement.item = None
|
||||||
|
locations.append(placement)
|
||||||
|
|
||||||
|
if allow_excluded:
|
||||||
|
# check if partial fill is the result of excluded locations, in which case retry
|
||||||
|
excluded_locations = [
|
||||||
|
location for location in locations
|
||||||
|
if location.progress_type == location.progress_type.EXCLUDED and not location.item
|
||||||
|
]
|
||||||
|
if excluded_locations:
|
||||||
|
for location in excluded_locations:
|
||||||
|
location.progress_type = location.progress_type.DEFAULT
|
||||||
|
fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
|
||||||
|
swap, on_place, allow_partial, False)
|
||||||
|
for location in excluded_locations:
|
||||||
|
if not location.item:
|
||||||
|
location.progress_type = location.progress_type.EXCLUDED
|
||||||
|
|
||||||
|
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0:
|
||||||
# There are leftover unplaceable items and locations that won't accept them
|
# There are leftover unplaceable items and locations that won't accept them
|
||||||
if world.can_beat_game():
|
if world.can_beat_game():
|
||||||
logging.warning(
|
logging.warning(
|
||||||
@@ -133,36 +184,219 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
|||||||
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
|
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
|
||||||
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
||||||
|
|
||||||
|
item_pool.extend(unplaced_items)
|
||||||
|
|
||||||
|
|
||||||
|
def remaining_fill(world: MultiWorld,
|
||||||
|
locations: typing.List[Location],
|
||||||
|
itempool: typing.List[Item]) -> None:
|
||||||
|
unplaced_items: typing.List[Item] = []
|
||||||
|
placements: typing.List[Location] = []
|
||||||
|
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||||
|
while locations and itempool:
|
||||||
|
item_to_place = itempool.pop()
|
||||||
|
spot_to_fill: typing.Optional[Location] = None
|
||||||
|
|
||||||
|
for i, location in enumerate(locations):
|
||||||
|
if location.item_rule(item_to_place):
|
||||||
|
# popping by index is faster than removing by content,
|
||||||
|
spot_to_fill = locations.pop(i)
|
||||||
|
# skipping a scan for the element
|
||||||
|
break
|
||||||
|
|
||||||
|
else:
|
||||||
|
# we filled all reachable spots.
|
||||||
|
# try swapping this item with previously placed items
|
||||||
|
|
||||||
|
for (i, location) in enumerate(placements):
|
||||||
|
placed_item = location.item
|
||||||
|
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||||
|
# number of times we will swap an individual item to prevent this
|
||||||
|
|
||||||
|
if swapped_items[placed_item.player,
|
||||||
|
placed_item.name] > 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
location.item = None
|
||||||
|
placed_item.location = None
|
||||||
|
if location.item_rule(item_to_place):
|
||||||
|
# Add this item to the existing placement, and
|
||||||
|
# add the old item to the back of the queue
|
||||||
|
spot_to_fill = placements.pop(i)
|
||||||
|
|
||||||
|
swapped_items[placed_item.player,
|
||||||
|
placed_item.name] += 1
|
||||||
|
|
||||||
|
itempool.append(placed_item)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
# Item can't be placed here, restore original item
|
||||||
|
location.item = placed_item
|
||||||
|
placed_item.location = location
|
||||||
|
|
||||||
|
if spot_to_fill is None:
|
||||||
|
# Can't place this item, move on to the next
|
||||||
|
unplaced_items.append(item_to_place)
|
||||||
|
continue
|
||||||
|
|
||||||
|
world.push_item(spot_to_fill, item_to_place, False)
|
||||||
|
placements.append(spot_to_fill)
|
||||||
|
|
||||||
|
if unplaced_items and locations:
|
||||||
|
# There are leftover unplaceable items and locations that won't accept them
|
||||||
|
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
|
||||||
|
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
||||||
|
|
||||||
itempool.extend(unplaced_items)
|
itempool.extend(unplaced_items)
|
||||||
|
|
||||||
|
|
||||||
|
def fast_fill(world: MultiWorld,
|
||||||
|
item_pool: typing.List[Item],
|
||||||
|
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
|
||||||
|
placing = min(len(item_pool), len(fill_locations))
|
||||||
|
for item, location in zip(item_pool, fill_locations):
|
||||||
|
world.push_item(location, item, False)
|
||||||
|
return item_pool[placing:], fill_locations[placing:]
|
||||||
|
|
||||||
|
|
||||||
|
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
|
||||||
|
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||||
|
minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
|
||||||
|
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
|
||||||
|
not location.can_reach(maximum_exploration_state)]
|
||||||
|
for location in unreachable_locations:
|
||||||
|
if (location.item is not None and location.item.advancement and location.address is not None and not
|
||||||
|
location.locked and location.item.player not in minimal_players):
|
||||||
|
pool.append(location.item)
|
||||||
|
state.remove(location.item)
|
||||||
|
location.item = None
|
||||||
|
location.event = False
|
||||||
|
if location in state.events:
|
||||||
|
state.events.remove(location)
|
||||||
|
locations.append(location)
|
||||||
|
if pool and locations:
|
||||||
|
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||||
|
fill_restrictive(world, state, locations, pool)
|
||||||
|
|
||||||
|
|
||||||
|
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
|
||||||
|
maximum_exploration_state = sweep_from_pool(state)
|
||||||
|
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
||||||
|
if unreachable_locations:
|
||||||
|
def forbid_important_item_rule(item: Item):
|
||||||
|
return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
|
||||||
|
|
||||||
|
for location in unreachable_locations:
|
||||||
|
add_item_rule(location, forbid_important_item_rule)
|
||||||
|
|
||||||
|
|
||||||
|
def distribute_early_items(world: MultiWorld,
|
||||||
|
fill_locations: typing.List[Location],
|
||||||
|
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
|
||||||
|
""" returns new fill_locations and itempool """
|
||||||
|
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
|
||||||
|
for player in world.player_ids:
|
||||||
|
items = itertools.chain(world.early_items[player], world.local_early_items[player])
|
||||||
|
for item in items:
|
||||||
|
early_items_count[item, player] = [world.early_items[player].get(item, 0),
|
||||||
|
world.local_early_items[player].get(item, 0)]
|
||||||
|
if early_items_count:
|
||||||
|
early_locations: typing.List[Location] = []
|
||||||
|
early_priority_locations: typing.List[Location] = []
|
||||||
|
loc_indexes_to_remove: typing.Set[int] = set()
|
||||||
|
base_state = world.state.copy()
|
||||||
|
base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
|
||||||
|
for i, loc in enumerate(fill_locations):
|
||||||
|
if loc.can_reach(base_state):
|
||||||
|
if loc.progress_type == LocationProgressType.PRIORITY:
|
||||||
|
early_priority_locations.append(loc)
|
||||||
|
else:
|
||||||
|
early_locations.append(loc)
|
||||||
|
loc_indexes_to_remove.add(i)
|
||||||
|
fill_locations = [loc for i, loc in enumerate(fill_locations) if i not in loc_indexes_to_remove]
|
||||||
|
|
||||||
|
early_prog_items: typing.List[Item] = []
|
||||||
|
early_rest_items: typing.List[Item] = []
|
||||||
|
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
|
||||||
|
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
|
||||||
|
item_indexes_to_remove: typing.Set[int] = set()
|
||||||
|
for i, item in enumerate(itempool):
|
||||||
|
if (item.name, item.player) in early_items_count:
|
||||||
|
if item.advancement:
|
||||||
|
if early_items_count[item.name, item.player][1]:
|
||||||
|
early_local_prog_items[item.player].append(item)
|
||||||
|
early_items_count[item.name, item.player][1] -= 1
|
||||||
|
else:
|
||||||
|
early_prog_items.append(item)
|
||||||
|
early_items_count[item.name, item.player][0] -= 1
|
||||||
|
else:
|
||||||
|
if early_items_count[item.name, item.player][1]:
|
||||||
|
early_local_rest_items[item.player].append(item)
|
||||||
|
early_items_count[item.name, item.player][1] -= 1
|
||||||
|
else:
|
||||||
|
early_rest_items.append(item)
|
||||||
|
early_items_count[item.name, item.player][0] -= 1
|
||||||
|
item_indexes_to_remove.add(i)
|
||||||
|
if early_items_count[item.name, item.player] == [0, 0]:
|
||||||
|
del early_items_count[item.name, item.player]
|
||||||
|
if len(early_items_count) == 0:
|
||||||
|
break
|
||||||
|
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
|
||||||
|
for player in world.player_ids:
|
||||||
|
player_local = early_local_rest_items[player]
|
||||||
|
fill_restrictive(world, base_state,
|
||||||
|
[loc for loc in early_locations if loc.player == player],
|
||||||
|
player_local, lock=True, allow_partial=True)
|
||||||
|
if player_local:
|
||||||
|
logging.warning(f"Could not fulfill rules of early items: {player_local}")
|
||||||
|
early_rest_items.extend(early_local_rest_items[player])
|
||||||
|
early_locations = [loc for loc in early_locations if not loc.item]
|
||||||
|
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True)
|
||||||
|
early_locations += early_priority_locations
|
||||||
|
for player in world.player_ids:
|
||||||
|
player_local = early_local_prog_items[player]
|
||||||
|
fill_restrictive(world, base_state,
|
||||||
|
[loc for loc in early_locations if loc.player == player],
|
||||||
|
player_local, lock=True, allow_partial=True)
|
||||||
|
if player_local:
|
||||||
|
logging.warning(f"Could not fulfill rules of early items: {player_local}")
|
||||||
|
early_prog_items.extend(player_local)
|
||||||
|
early_locations = [loc for loc in early_locations if not loc.item]
|
||||||
|
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True)
|
||||||
|
unplaced_early_items = early_rest_items + early_prog_items
|
||||||
|
if unplaced_early_items:
|
||||||
|
logging.warning("Ran out of early locations for early items. Failed to place "
|
||||||
|
f"{unplaced_early_items} early.")
|
||||||
|
itempool += unplaced_early_items
|
||||||
|
|
||||||
|
fill_locations.extend(early_locations)
|
||||||
|
world.random.shuffle(fill_locations)
|
||||||
|
return fill_locations, itempool
|
||||||
|
|
||||||
|
|
||||||
def distribute_items_restrictive(world: MultiWorld) -> None:
|
def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||||
fill_locations = sorted(world.get_unfilled_locations())
|
fill_locations = sorted(world.get_unfilled_locations())
|
||||||
world.random.shuffle(fill_locations)
|
world.random.shuffle(fill_locations)
|
||||||
|
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
itempool = sorted(world.itempool)
|
itempool = sorted(world.itempool)
|
||||||
world.random.shuffle(itempool)
|
world.random.shuffle(itempool)
|
||||||
|
|
||||||
|
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
|
||||||
|
|
||||||
progitempool: typing.List[Item] = []
|
progitempool: typing.List[Item] = []
|
||||||
nonexcludeditempool: typing.List[Item] = []
|
usefulitempool: typing.List[Item] = []
|
||||||
localrestitempool: typing.Dict[int, typing.List[Item]] = {player: [] for player in range(1, world.players + 1)}
|
filleritempool: typing.List[Item] = []
|
||||||
nonlocalrestitempool: typing.List[Item] = []
|
|
||||||
restitempool: typing.List[Item] = []
|
|
||||||
|
|
||||||
for item in itempool:
|
for item in itempool:
|
||||||
if item.advancement:
|
if item.advancement:
|
||||||
progitempool.append(item)
|
progitempool.append(item)
|
||||||
elif item.useful: # this only gets nonprogression items which should not appear in excluded locations
|
elif item.useful:
|
||||||
nonexcludeditempool.append(item)
|
usefulitempool.append(item)
|
||||||
elif item.name in world.local_items[item.player].value:
|
|
||||||
localrestitempool[item.player].append(item)
|
|
||||||
elif item.name in world.non_local_items[item.player].value:
|
|
||||||
nonlocalrestitempool.append(item)
|
|
||||||
else:
|
else:
|
||||||
restitempool.append(item)
|
filleritempool.append(item)
|
||||||
|
|
||||||
call_all(world, "fill_hook", progitempool, nonexcludeditempool,
|
call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
|
||||||
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
|
|
||||||
|
|
||||||
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
||||||
loc_type: [] for loc_type in LocationProgressType}
|
loc_type: [] for loc_type in LocationProgressType}
|
||||||
@@ -174,60 +408,44 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
|||||||
defaultlocations = locations[LocationProgressType.DEFAULT]
|
defaultlocations = locations[LocationProgressType.DEFAULT]
|
||||||
excludedlocations = locations[LocationProgressType.EXCLUDED]
|
excludedlocations = locations[LocationProgressType.EXCLUDED]
|
||||||
|
|
||||||
fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
|
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later
|
||||||
|
lock_later = []
|
||||||
|
|
||||||
|
def mark_for_locking(location: Location):
|
||||||
|
nonlocal lock_later
|
||||||
|
lock_later.append(location)
|
||||||
|
|
||||||
if prioritylocations:
|
if prioritylocations:
|
||||||
|
# "priority fill"
|
||||||
|
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
|
||||||
|
accessibility_corrections(world, world.state, prioritylocations, progitempool)
|
||||||
defaultlocations = prioritylocations + defaultlocations
|
defaultlocations = prioritylocations + defaultlocations
|
||||||
|
|
||||||
if progitempool:
|
if progitempool:
|
||||||
|
# "progression fill"
|
||||||
fill_restrictive(world, world.state, defaultlocations, progitempool)
|
fill_restrictive(world, world.state, defaultlocations, progitempool)
|
||||||
if progitempool:
|
if progitempool:
|
||||||
raise FillError(
|
raise FillError(
|
||||||
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
|
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
|
||||||
|
accessibility_corrections(world, world.state, defaultlocations)
|
||||||
|
|
||||||
if nonexcludeditempool:
|
for location in lock_later:
|
||||||
world.random.shuffle(defaultlocations)
|
if location.item:
|
||||||
# needs logical fill to not conflict with local items
|
location.locked = True
|
||||||
fill_restrictive(
|
del mark_for_locking, lock_later
|
||||||
world, world.state, defaultlocations, nonexcludeditempool)
|
|
||||||
if nonexcludeditempool:
|
|
||||||
raise FillError(
|
|
||||||
f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations')
|
|
||||||
|
|
||||||
defaultlocations = defaultlocations + excludedlocations
|
inaccessible_location_rules(world, world.state, defaultlocations)
|
||||||
world.random.shuffle(defaultlocations)
|
|
||||||
|
|
||||||
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
|
remaining_fill(world, excludedlocations, filleritempool)
|
||||||
local_locations: typing.Dict[int, typing.List[Location]] = {player: [] for player in world.player_ids}
|
if excludedlocations:
|
||||||
for location in defaultlocations:
|
raise FillError(
|
||||||
local_locations[location.player].append(location)
|
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
|
||||||
for player_locations in local_locations.values():
|
|
||||||
world.random.shuffle(player_locations)
|
|
||||||
|
|
||||||
for player, items in localrestitempool.items(): # items already shuffled
|
restitempool = usefulitempool + filleritempool
|
||||||
player_local_locations = local_locations[player]
|
|
||||||
for item_to_place in items:
|
|
||||||
if not player_local_locations:
|
|
||||||
logging.warning(f"Ran out of local locations for player {player}, "
|
|
||||||
f"cannot place {item_to_place}.")
|
|
||||||
break
|
|
||||||
spot_to_fill = player_local_locations.pop()
|
|
||||||
world.push_item(spot_to_fill, item_to_place, False)
|
|
||||||
defaultlocations.remove(spot_to_fill)
|
|
||||||
|
|
||||||
for item_to_place in nonlocalrestitempool:
|
remaining_fill(world, defaultlocations, restitempool)
|
||||||
for i, location in enumerate(defaultlocations):
|
|
||||||
if location.player != item_to_place.player:
|
|
||||||
world.push_item(defaultlocations.pop(i), item_to_place, False)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise Exception(f"Could not place non_local_item {item_to_place} among {defaultlocations}. "
|
|
||||||
f"Too many non-local items for too few remaining locations.")
|
|
||||||
|
|
||||||
world.random.shuffle(defaultlocations)
|
unplaced = restitempool
|
||||||
|
|
||||||
restitempool, defaultlocations = fast_fill(
|
|
||||||
world, restitempool, defaultlocations)
|
|
||||||
unplaced = progitempool + restitempool
|
|
||||||
unfilled = defaultlocations
|
unfilled = defaultlocations
|
||||||
|
|
||||||
if unplaced or unfilled:
|
if unplaced or unfilled:
|
||||||
@@ -241,15 +459,6 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
|||||||
logging.info(f'Per-Player counts: {print_data})')
|
logging.info(f'Per-Player counts: {print_data})')
|
||||||
|
|
||||||
|
|
||||||
def fast_fill(world: MultiWorld,
|
|
||||||
item_pool: typing.List[Item],
|
|
||||||
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
|
|
||||||
placing = min(len(item_pool), len(fill_locations))
|
|
||||||
for item, location in zip(item_pool, fill_locations):
|
|
||||||
world.push_item(location, item, False)
|
|
||||||
return item_pool[placing:], fill_locations[placing:]
|
|
||||||
|
|
||||||
|
|
||||||
def flood_items(world: MultiWorld) -> None:
|
def flood_items(world: MultiWorld) -> None:
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
world.random.shuffle(world.itempool)
|
world.random.shuffle(world.itempool)
|
||||||
@@ -332,16 +541,16 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
|
|||||||
checked_locations: typing.Set[Location] = set()
|
checked_locations: typing.Set[Location] = set()
|
||||||
unchecked_locations: typing.Set[Location] = set(world.get_locations())
|
unchecked_locations: typing.Set[Location] = set(world.get_locations())
|
||||||
|
|
||||||
reachable_locations_count: typing.Dict[int, int] = {
|
|
||||||
player: 0
|
|
||||||
for player in world.player_ids
|
|
||||||
if len(world.get_filled_locations(player)) != 0
|
|
||||||
}
|
|
||||||
total_locations_count: typing.Counter[int] = Counter(
|
total_locations_count: typing.Counter[int] = Counter(
|
||||||
location.player
|
location.player
|
||||||
for location in world.get_locations()
|
for location in world.get_locations()
|
||||||
if not location.locked
|
if not location.locked
|
||||||
)
|
)
|
||||||
|
reachable_locations_count: typing.Dict[int, int] = {
|
||||||
|
player: 0
|
||||||
|
for player in world.player_ids
|
||||||
|
if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
|
||||||
|
}
|
||||||
balanceable_players = {
|
balanceable_players = {
|
||||||
player: balanceable_players[player]
|
player: balanceable_players[player]
|
||||||
for player in balanceable_players
|
for player in balanceable_players
|
||||||
@@ -358,6 +567,10 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
|
|||||||
def item_percentage(player: int, num: int) -> float:
|
def item_percentage(player: int, num: int) -> float:
|
||||||
return num / total_locations_count[player]
|
return num / total_locations_count[player]
|
||||||
|
|
||||||
|
# If there are no locations that aren't locked, there's no point in attempting to balance progression.
|
||||||
|
if len(total_locations_count) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# Gather non-locked locations.
|
# Gather non-locked locations.
|
||||||
# This ensures that only shuffled locations get counted for progression balancing,
|
# This ensures that only shuffled locations get counted for progression balancing,
|
||||||
@@ -526,6 +739,17 @@ def distribute_planned(world: MultiWorld) -> None:
|
|||||||
else:
|
else:
|
||||||
warn(warning, force)
|
warn(warning, force)
|
||||||
|
|
||||||
|
swept_state = world.state.copy()
|
||||||
|
swept_state.sweep_for_events()
|
||||||
|
reachable = frozenset(world.get_reachable_locations(swept_state))
|
||||||
|
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||||
|
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
||||||
|
for loc in world.get_unfilled_locations():
|
||||||
|
if loc in reachable:
|
||||||
|
early_locations[loc.player].append(loc.name)
|
||||||
|
else: # not reachable with swept state
|
||||||
|
non_early_locations[loc.player].append(loc.name)
|
||||||
|
|
||||||
# TODO: remove. Preferably by implementing key drop
|
# TODO: remove. Preferably by implementing key drop
|
||||||
from worlds.alttp.Regions import key_drop_data
|
from worlds.alttp.Regions import key_drop_data
|
||||||
world_name_lookup = world.world_name_lookup
|
world_name_lookup = world.world_name_lookup
|
||||||
@@ -541,7 +765,39 @@ def distribute_planned(world: MultiWorld) -> None:
|
|||||||
if 'from_pool' not in block:
|
if 'from_pool' not in block:
|
||||||
block['from_pool'] = True
|
block['from_pool'] = True
|
||||||
if 'world' not in block:
|
if 'world' not in block:
|
||||||
block['world'] = False
|
target_world = False
|
||||||
|
else:
|
||||||
|
target_world = block['world']
|
||||||
|
|
||||||
|
if target_world is False or world.players == 1: # target own world
|
||||||
|
worlds: typing.Set[int] = {player}
|
||||||
|
elif target_world is True: # target any worlds besides own
|
||||||
|
worlds = set(world.player_ids) - {player}
|
||||||
|
elif target_world is None: # target all worlds
|
||||||
|
worlds = set(world.player_ids)
|
||||||
|
elif type(target_world) == list: # list of target worlds
|
||||||
|
worlds = set()
|
||||||
|
for listed_world in target_world:
|
||||||
|
if listed_world not in world_name_lookup:
|
||||||
|
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||||
|
block['force'])
|
||||||
|
continue
|
||||||
|
worlds.add(world_name_lookup[listed_world])
|
||||||
|
elif type(target_world) == int: # target world by slot number
|
||||||
|
if target_world not in range(1, world.players + 1):
|
||||||
|
failed(
|
||||||
|
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
|
||||||
|
block['force'])
|
||||||
|
continue
|
||||||
|
worlds = {target_world}
|
||||||
|
else: # target world by slot name
|
||||||
|
if target_world not in world_name_lookup:
|
||||||
|
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||||
|
block['force'])
|
||||||
|
continue
|
||||||
|
worlds = {world_name_lookup[target_world]}
|
||||||
|
block['world'] = worlds
|
||||||
|
|
||||||
items: block_value = []
|
items: block_value = []
|
||||||
if "items" in block:
|
if "items" in block:
|
||||||
items = block["items"]
|
items = block["items"]
|
||||||
@@ -578,6 +834,16 @@ def distribute_planned(world: MultiWorld) -> None:
|
|||||||
for key, value in locations.items():
|
for key, value in locations.items():
|
||||||
location_list += [key] * value
|
location_list += [key] * value
|
||||||
locations = location_list
|
locations = location_list
|
||||||
|
|
||||||
|
if "early_locations" in locations:
|
||||||
|
locations.remove("early_locations")
|
||||||
|
for player in worlds:
|
||||||
|
locations += early_locations[player]
|
||||||
|
if "non_early_locations" in locations:
|
||||||
|
locations.remove("non_early_locations")
|
||||||
|
for player in worlds:
|
||||||
|
locations += non_early_locations[player]
|
||||||
|
|
||||||
block['locations'] = locations
|
block['locations'] = locations
|
||||||
|
|
||||||
if not block['count']:
|
if not block['count']:
|
||||||
@@ -613,41 +879,13 @@ def distribute_planned(world: MultiWorld) -> None:
|
|||||||
for placement in plando_blocks:
|
for placement in plando_blocks:
|
||||||
player = placement['player']
|
player = placement['player']
|
||||||
try:
|
try:
|
||||||
target_world = placement['world']
|
worlds = placement['world']
|
||||||
locations = placement['locations']
|
locations = placement['locations']
|
||||||
items = placement['items']
|
items = placement['items']
|
||||||
maxcount = placement['count']['target']
|
maxcount = placement['count']['target']
|
||||||
from_pool = placement['from_pool']
|
from_pool = placement['from_pool']
|
||||||
if target_world is False or world.players == 1: # target own world
|
|
||||||
worlds: typing.Set[int] = {player}
|
|
||||||
elif target_world is True: # target any worlds besides own
|
|
||||||
worlds = set(world.player_ids) - {player}
|
|
||||||
elif target_world is None: # target all worlds
|
|
||||||
worlds = set(world.player_ids)
|
|
||||||
elif type(target_world) == list: # list of target worlds
|
|
||||||
worlds = set()
|
|
||||||
for listed_world in target_world:
|
|
||||||
if listed_world not in world_name_lookup:
|
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
|
||||||
placement['force'])
|
|
||||||
continue
|
|
||||||
worlds.add(world_name_lookup[listed_world])
|
|
||||||
elif type(target_world) == int: # target world by slot number
|
|
||||||
if target_world not in range(1, world.players + 1):
|
|
||||||
failed(
|
|
||||||
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
|
|
||||||
placement['force'])
|
|
||||||
continue
|
|
||||||
worlds = {target_world}
|
|
||||||
else: # target world by slot name
|
|
||||||
if target_world not in world_name_lookup:
|
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
|
||||||
placement['force'])
|
|
||||||
continue
|
|
||||||
worlds = {world_name_lookup[target_world]}
|
|
||||||
|
|
||||||
candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
|
candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
|
||||||
worlds))
|
|
||||||
world.random.shuffle(candidates)
|
world.random.shuffle(candidates)
|
||||||
world.random.shuffle(items)
|
world.random.shuffle(items)
|
||||||
count = 0
|
count = 0
|
||||||
|
|||||||
233
Generate.py
233
Generate.py
@@ -2,14 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import random
|
|
||||||
import urllib.request
|
|
||||||
import urllib.parse
|
|
||||||
from typing import Set, Dict, Tuple, Callable, Any, Union
|
|
||||||
import os
|
import os
|
||||||
from collections import Counter, ChainMap
|
import random
|
||||||
import string
|
import string
|
||||||
import enum
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections import ChainMap, Counter
|
||||||
|
from typing import Any, Callable, Dict, Tuple, Union
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
@@ -18,55 +17,16 @@ ModuleUpdate.update()
|
|||||||
import Utils
|
import Utils
|
||||||
from worlds.alttp import Options as LttPOptions
|
from worlds.alttp import Options as LttPOptions
|
||||||
from worlds.generic import PlandoConnection
|
from worlds.generic import PlandoConnection
|
||||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
|
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, user_path
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from BaseClasses import seeddigits, get_seed
|
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||||
import Options
|
import Options
|
||||||
from worlds.alttp import Bosses
|
|
||||||
from worlds.alttp.Text import TextTable
|
from worlds.alttp.Text import TextTable
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
import copy
|
import copy
|
||||||
|
|
||||||
|
|
||||||
class PlandoSettings(enum.IntFlag):
|
|
||||||
items = 0b0001
|
|
||||||
connections = 0b0010
|
|
||||||
texts = 0b0100
|
|
||||||
bosses = 0b1000
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_option_string(cls, option_string: str) -> PlandoSettings:
|
|
||||||
result = cls(0)
|
|
||||||
for part in option_string.split(","):
|
|
||||||
part = part.strip().lower()
|
|
||||||
if part:
|
|
||||||
result = cls._handle_part(part, result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_set(cls, option_set: Set[str]) -> PlandoSettings:
|
|
||||||
result = cls(0)
|
|
||||||
for part in option_set:
|
|
||||||
result = cls._handle_part(part, result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _handle_part(cls, part: str, base: PlandoSettings) -> PlandoSettings:
|
|
||||||
try:
|
|
||||||
part = cls[part]
|
|
||||||
except Exception as e:
|
|
||||||
raise KeyError(f"{part} is not a recognized name for a plando module. "
|
|
||||||
f"Known options: {', '.join(flag.name for flag in cls)}") from e
|
|
||||||
else:
|
|
||||||
return base | part
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
if self.value:
|
|
||||||
return ", ".join((flag.name for flag in PlandoSettings if self.value & flag.value))
|
|
||||||
return "Off"
|
|
||||||
|
|
||||||
|
|
||||||
def mystery_argparse():
|
def mystery_argparse():
|
||||||
options = get_options()
|
options = get_options()
|
||||||
defaults = options["generator"]
|
defaults = options["generator"]
|
||||||
@@ -84,11 +44,6 @@ def mystery_argparse():
|
|||||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
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('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
|
||||||
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
|
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
|
||||||
parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"],
|
|
||||||
help="Path to the 1.0 JP LttP Baserom.") # absolute, relative to cwd or relative to app path
|
|
||||||
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"],
|
|
||||||
help="Path to the 1.0 JP SM Baserom.")
|
|
||||||
parser.add_argument('--enemizercli', default=resolve_path(defaults["enemizer_path"], local_path))
|
|
||||||
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
|
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
|
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||||
parser.add_argument('--race', action='store_true', default=defaults["race"])
|
parser.add_argument('--race', action='store_true', default=defaults["race"])
|
||||||
@@ -98,12 +53,14 @@ def mystery_argparse():
|
|||||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
||||||
parser.add_argument('--plando', default=defaults["plando_options"],
|
parser.add_argument('--plando', default=defaults["plando_options"],
|
||||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
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()
|
args = parser.parse_args()
|
||||||
if not os.path.isabs(args.weights_file_path):
|
if not os.path.isabs(args.weights_file_path):
|
||||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||||
if not os.path.isabs(args.meta_file_path):
|
if not os.path.isabs(args.meta_file_path):
|
||||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||||
args.plando: PlandoSettings = PlandoSettings.from_option_string(args.plando)
|
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||||
return args, options
|
return args, options
|
||||||
|
|
||||||
|
|
||||||
@@ -116,10 +73,12 @@ def main(args=None, callback=ERmain):
|
|||||||
args, options = mystery_argparse()
|
args, options = mystery_argparse()
|
||||||
|
|
||||||
seed = get_seed(args.seed)
|
seed = get_seed(args.seed)
|
||||||
|
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||||
random.seed(seed)
|
random.seed(seed)
|
||||||
seed_name = get_seed_name(random)
|
seed_name = get_seed_name(random)
|
||||||
|
|
||||||
if args.race:
|
if args.race:
|
||||||
|
logging.info("Race mode enabled. Using non-deterministic random source.")
|
||||||
random.seed() # reset to time-based random source
|
random.seed() # reset to time-based random source
|
||||||
|
|
||||||
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
||||||
@@ -128,15 +87,15 @@ def main(args=None, callback=ERmain):
|
|||||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
|
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
|
||||||
print(f"Weights: {args.weights_file_path} >> "
|
logging.info(f"Weights: {args.weights_file_path} >> "
|
||||||
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
|
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
|
||||||
|
|
||||||
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
||||||
try:
|
try:
|
||||||
meta_weights = read_weights_yamls(args.meta_file_path)[-1]
|
meta_weights = read_weights_yamls(args.meta_file_path)[-1]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
|
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
|
||||||
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
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
|
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
|
||||||
del(meta_weights["meta_description"])
|
del(meta_weights["meta_description"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -149,7 +108,7 @@ def main(args=None, callback=ERmain):
|
|||||||
player_files = {}
|
player_files = {}
|
||||||
for file in os.scandir(args.player_files_path):
|
for file in os.scandir(args.player_files_path):
|
||||||
fname = file.name
|
fname = file.name
|
||||||
if file.is_file() and not file.name.startswith(".") and \
|
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}:
|
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||||
path = os.path.join(args.player_files_path, fname)
|
path = os.path.join(args.player_files_path, fname)
|
||||||
try:
|
try:
|
||||||
@@ -160,32 +119,30 @@ def main(args=None, callback=ERmain):
|
|||||||
# sort dict for consistent results across platforms:
|
# sort dict for consistent results across platforms:
|
||||||
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
||||||
for filename, yaml_data in weights_cache.items():
|
for filename, yaml_data in weights_cache.items():
|
||||||
for yaml in yaml_data:
|
if filename not in {args.meta_file_path, args.weights_file_path}:
|
||||||
print(f"P{player_id} Weights: {filename} >> "
|
for yaml in yaml_data:
|
||||||
f"{get_choice('description', yaml, 'No description specified')}")
|
logging.info(f"P{player_id} Weights: {filename} >> "
|
||||||
player_files[player_id] = filename
|
f"{get_choice('description', yaml, 'No description specified')}")
|
||||||
player_id += 1
|
player_files[player_id] = filename
|
||||||
|
player_id += 1
|
||||||
|
|
||||||
args.multi = max(player_id - 1, args.multi)
|
args.multi = max(player_id - 1, args.multi)
|
||||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
|
||||||
f"{args.plando}")
|
f"{seed_name} Seed {seed} with plando: {args.plando}")
|
||||||
|
|
||||||
if not weights_cache:
|
if not weights_cache:
|
||||||
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
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.")
|
f"A mix is also permitted.")
|
||||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||||
erargs.seed = seed
|
erargs.seed = seed
|
||||||
|
erargs.plando_options = args.plando
|
||||||
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
|
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
|
||||||
erargs.spoiler = args.spoiler
|
erargs.spoiler = args.spoiler
|
||||||
erargs.race = args.race
|
erargs.race = args.race
|
||||||
erargs.outputname = seed_name
|
erargs.outputname = seed_name
|
||||||
erargs.outputpath = args.outputpath
|
erargs.outputpath = args.outputpath
|
||||||
|
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
|
||||||
|
|
||||||
erargs.lttp_rom = args.lttp_rom
|
|
||||||
erargs.sm_rom = args.sm_rom
|
|
||||||
erargs.enemizercli = args.enemizercli
|
|
||||||
|
|
||||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
|
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
|
||||||
@@ -235,15 +192,15 @@ def main(args=None, callback=ERmain):
|
|||||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
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] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||||
|
|
||||||
player += 1
|
player += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f'No weights specified for player {player}')
|
raise RuntimeError(f'No weights specified for player {player}')
|
||||||
|
|
||||||
if len(set(erargs.name.values())) != len(erargs.name):
|
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
||||||
|
|
||||||
if args.yaml_output:
|
if args.yaml_output:
|
||||||
import yaml
|
import yaml
|
||||||
@@ -326,11 +283,11 @@ class SafeDict(dict):
|
|||||||
|
|
||||||
|
|
||||||
def handle_name(name: str, player: int, name_counter: Counter):
|
def handle_name(name: str, player: int, name_counter: Counter):
|
||||||
name_counter[name] += 1
|
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 = "%".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],
|
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
||||||
NUMBER=(name_counter[name] if name_counter[
|
NUMBER=(number if number > 1 else ''),
|
||||||
name] > 1 else ''),
|
|
||||||
player=player,
|
player=player,
|
||||||
PLAYER=(player if player > 1 else '')))
|
PLAYER=(player if player > 1 else '')))
|
||||||
new_name = new_name.strip()[:16]
|
new_name = new_name.strip()[:16]
|
||||||
@@ -346,19 +303,6 @@ def prefer_int(input_data: str) -> Union[str, int]:
|
|||||||
return input_data
|
return input_data
|
||||||
|
|
||||||
|
|
||||||
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
|
||||||
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
|
||||||
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
|
||||||
Bosses.boss_location_table}
|
|
||||||
|
|
||||||
boss_shuffle_options = {None: 'none',
|
|
||||||
'none': 'none',
|
|
||||||
'basic': 'basic',
|
|
||||||
'full': 'full',
|
|
||||||
'chaos': 'chaos',
|
|
||||||
'singularity': 'singularity'
|
|
||||||
}
|
|
||||||
|
|
||||||
goals = {
|
goals = {
|
||||||
'ganon': 'ganon',
|
'ganon': 'ganon',
|
||||||
'crystals': 'crystals',
|
'crystals': 'crystals',
|
||||||
@@ -400,7 +344,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
|||||||
if option_key in options:
|
if option_key in options:
|
||||||
if options[option_key].supports_weighting:
|
if options[option_key].supports_weighting:
|
||||||
return get_choice(option_key, category_dict)
|
return get_choice(option_key, category_dict)
|
||||||
return options[option_key]
|
return category_dict[option_key]
|
||||||
if game == "A Link to the Past": # TODO wow i hate this
|
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",
|
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_percentage", "triforce_pieces_available", "triforce_pieces_extra",
|
||||||
@@ -465,42 +409,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
|
|||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
|
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
||||||
if boss_shuffle in boss_shuffle_options:
|
|
||||||
return boss_shuffle_options[boss_shuffle]
|
|
||||||
elif PlandoSettings.bosses in plando_options:
|
|
||||||
options = boss_shuffle.lower().split(";")
|
|
||||||
remainder_shuffle = "none" # vanilla
|
|
||||||
bosses = []
|
|
||||||
for boss in options:
|
|
||||||
if boss in boss_shuffle_options:
|
|
||||||
remainder_shuffle = boss_shuffle_options[boss]
|
|
||||||
elif "-" in boss:
|
|
||||||
loc, boss_name = boss.split("-")
|
|
||||||
if boss_name not in available_boss_names:
|
|
||||||
raise ValueError(f"Unknown Boss name {boss_name}")
|
|
||||||
if loc not in available_boss_locations:
|
|
||||||
raise ValueError(f"Unknown Boss Location {loc}")
|
|
||||||
level = ''
|
|
||||||
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
|
||||||
# split off level
|
|
||||||
loc = loc.split(" ")
|
|
||||||
level = f" {loc[-1]}"
|
|
||||||
loc = " ".join(loc[:-1])
|
|
||||||
loc = loc.title().replace("Of", "of")
|
|
||||||
if not Bosses.can_place_boss(boss_name.title(), loc, level):
|
|
||||||
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
|
|
||||||
bosses.append(boss)
|
|
||||||
elif boss not in available_boss_names:
|
|
||||||
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
|
|
||||||
else:
|
|
||||||
bosses.append(boss)
|
|
||||||
return ";".join(bosses + [remainder_shuffle])
|
|
||||||
else:
|
|
||||||
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
|
|
||||||
|
|
||||||
|
|
||||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
|
|
||||||
if option_key in game_weights:
|
if option_key in game_weights:
|
||||||
try:
|
try:
|
||||||
if not option.supports_weighting:
|
if not option.supports_weighting:
|
||||||
@@ -511,13 +420,12 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
||||||
else:
|
else:
|
||||||
if hasattr(player_option, "verify"):
|
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||||
player_option.verify(AutoWorldRegister.world_types[ret.game])
|
|
||||||
else:
|
else:
|
||||||
setattr(ret, option_key, option(option.default))
|
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: PlandoSettings = PlandoSettings.bosses):
|
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||||
if "linked_options" in weights:
|
if "linked_options" in weights:
|
||||||
weights = roll_linked_options(weights)
|
weights = roll_linked_options(weights)
|
||||||
|
|
||||||
@@ -530,7 +438,7 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
|
|||||||
if tuplize_version(version) > version_tuple:
|
if tuplize_version(version) > version_tuple:
|
||||||
raise Exception(f"Settings reports required version of generator is at least {version}, "
|
raise Exception(f"Settings reports required version of generator is at least {version}, "
|
||||||
f"however generator is of version {__version__}")
|
f"however generator is of version {__version__}")
|
||||||
required_plando_options = PlandoSettings.from_option_string(requirements.get("plando", ""))
|
required_plando_options = PlandoOptions.from_option_string(requirements.get("plando", ""))
|
||||||
if required_plando_options not in plando_options:
|
if required_plando_options not in plando_options:
|
||||||
if required_plando_options:
|
if required_plando_options:
|
||||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||||
@@ -542,6 +450,11 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
|
|||||||
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
|
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
|
||||||
|
|
||||||
ret.game = get_choice("game", weights)
|
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:
|
if ret.game not in weights:
|
||||||
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
|
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
|
||||||
|
|
||||||
@@ -556,31 +469,29 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
|
|||||||
for option_key, option in Options.common_options.items():
|
for option_key, option in Options.common_options.items():
|
||||||
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
|
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
|
||||||
|
|
||||||
if ret.game in AutoWorldRegister.world_types:
|
for option_key, option in world_type.option_definitions.items():
|
||||||
for option_key, option in world_type.option_definitions.items():
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
handle_option(ret, game_weights, option_key, option)
|
for option_key, option in Options.per_game_common_options.items():
|
||||||
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
|
||||||
# skip setting this option if already set from common_options, defaulting to root option
|
if option_key not in world_type.option_definitions and \
|
||||||
if not (option_key in Options.common_options and option_key not in game_weights):
|
(option_key not in Options.common_options or option_key in game_weights):
|
||||||
handle_option(ret, game_weights, option_key, option)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
if PlandoSettings.items in plando_options:
|
if PlandoOptions.items in plando_options:
|
||||||
ret.plando_items = game_weights.get("plando_items", [])
|
ret.plando_items = game_weights.get("plando_items", [])
|
||||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||||
# bad hardcoded behavior to make this work for now
|
# bad hardcoded behavior to make this work for now
|
||||||
ret.plando_connections = []
|
ret.plando_connections = []
|
||||||
if PlandoSettings.connections in plando_options:
|
if PlandoOptions.connections in plando_options:
|
||||||
options = game_weights.get("plando_connections", [])
|
options = game_weights.get("plando_connections", [])
|
||||||
for placement in options:
|
for placement in options:
|
||||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||||
ret.plando_connections.append(PlandoConnection(
|
ret.plando_connections.append(PlandoConnection(
|
||||||
get_choice("entrance", placement),
|
get_choice("entrance", placement),
|
||||||
get_choice("exit", placement),
|
get_choice("exit", placement),
|
||||||
get_choice("direction", placement)
|
get_choice("direction", placement)
|
||||||
))
|
))
|
||||||
elif ret.game == "A Link to the Past":
|
elif ret.game == "A Link to the Past":
|
||||||
roll_alttp_settings(ret, game_weights, plando_options)
|
roll_alttp_settings(ret, game_weights, plando_options)
|
||||||
else:
|
|
||||||
raise Exception(f"Unsupported game {ret.game}")
|
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@@ -645,8 +556,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
|||||||
|
|
||||||
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
||||||
|
|
||||||
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
|
|
||||||
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
|
|
||||||
|
|
||||||
ret.enemy_damage = {None: 'default',
|
ret.enemy_damage = {None: 'default',
|
||||||
'default': 'default',
|
'default': 'default',
|
||||||
@@ -685,7 +594,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
|||||||
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||||
|
|
||||||
ret.plando_texts = {}
|
ret.plando_texts = {}
|
||||||
if PlandoSettings.texts in plando_options:
|
if PlandoOptions.texts in plando_options:
|
||||||
tt = TextTable()
|
tt = TextTable()
|
||||||
tt.removeUnwantedText()
|
tt.removeUnwantedText()
|
||||||
options = weights.get("plando_texts", [])
|
options = weights.get("plando_texts", [])
|
||||||
@@ -697,7 +606,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
|||||||
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
||||||
|
|
||||||
ret.plando_connections = []
|
ret.plando_connections = []
|
||||||
if PlandoSettings.connections in plando_options:
|
if PlandoOptions.connections in plando_options:
|
||||||
options = weights.get("plando_connections", [])
|
options = weights.get("plando_connections", [])
|
||||||
for placement in options:
|
for placement in options:
|
||||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||||
|
|||||||
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()
|
||||||
234
Launcher.py
234
Launcher.py
@@ -10,16 +10,26 @@ Scroll down to components= to add components to the launcher as well as setup.py
|
|||||||
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from os.path import isfile
|
|
||||||
import sys
|
|
||||||
from typing import Iterable, Sequence, Callable, Union, Optional
|
|
||||||
import subprocess
|
|
||||||
import itertools
|
import itertools
|
||||||
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox,\
|
import logging
|
||||||
is_windows, is_macos, is_linux
|
import multiprocessing
|
||||||
from shutil import which
|
|
||||||
import shlex
|
import shlex
|
||||||
from enum import Enum, auto
|
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():
|
def open_host_yaml():
|
||||||
@@ -32,7 +42,6 @@ def open_host_yaml():
|
|||||||
exe = which("open")
|
exe = which("open")
|
||||||
subprocess.Popen([exe, file])
|
subprocess.Popen([exe, file])
|
||||||
else:
|
else:
|
||||||
import webbrowser
|
|
||||||
webbrowser.open(file)
|
webbrowser.open(file)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,120 +56,54 @@ def open_patch():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
messagebox('Error', str(e), error=True)
|
messagebox('Error', str(e), error=True)
|
||||||
else:
|
else:
|
||||||
file, _, component = identify(filename)
|
file, component = identify(filename)
|
||||||
if file and component:
|
if file and component:
|
||||||
launch([*get_exe(component), file], component.cli)
|
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():
|
def browse_files():
|
||||||
file = user_path()
|
open_folder(user_path())
|
||||||
|
|
||||||
|
|
||||||
|
def open_folder(folder_path):
|
||||||
if is_linux:
|
if is_linux:
|
||||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||||
subprocess.Popen([exe, file])
|
subprocess.Popen([exe, folder_path])
|
||||||
elif is_macos:
|
elif is_macos:
|
||||||
exe = which("open")
|
exe = which("open")
|
||||||
subprocess.Popen([exe, file])
|
subprocess.Popen([exe, folder_path])
|
||||||
else:
|
else:
|
||||||
import webbrowser
|
webbrowser.open(folder_path)
|
||||||
webbrowser.open(file)
|
|
||||||
|
|
||||||
|
|
||||||
class Type(Enum):
|
components.extend([
|
||||||
TOOL = auto()
|
|
||||||
FUNC = auto() # not a real component
|
|
||||||
CLIENT = auto()
|
|
||||||
ADJUSTER = auto()
|
|
||||||
|
|
||||||
|
|
||||||
class SuffixIdentifier:
|
|
||||||
suffixes: Iterable[str]
|
|
||||||
|
|
||||||
def __init__(self, *args: str):
|
|
||||||
self.suffixes = args
|
|
||||||
|
|
||||||
def __call__(self, path: str):
|
|
||||||
if isinstance(path, str):
|
|
||||||
for suffix in self.suffixes:
|
|
||||||
if path.endswith(suffix):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class Component:
|
|
||||||
display_name: str
|
|
||||||
type: Optional[Type]
|
|
||||||
script_name: Optional[str]
|
|
||||||
frozen_name: Optional[str]
|
|
||||||
icon: str # just the name, no suffix
|
|
||||||
cli: bool
|
|
||||||
func: Optional[Callable]
|
|
||||||
file_identifier: Optional[Callable[[str], bool]]
|
|
||||||
|
|
||||||
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
|
|
||||||
cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None,
|
|
||||||
file_identifier: Optional[Callable[[str], bool]] = None):
|
|
||||||
self.display_name = display_name
|
|
||||||
self.script_name = script_name
|
|
||||||
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
|
|
||||||
self.icon = icon
|
|
||||||
self.cli = cli
|
|
||||||
self.type = component_type or \
|
|
||||||
None if not display_name else \
|
|
||||||
Type.FUNC if func else \
|
|
||||||
Type.CLIENT if 'Client' in display_name else \
|
|
||||||
Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL
|
|
||||||
self.func = func
|
|
||||||
self.file_identifier = file_identifier
|
|
||||||
|
|
||||||
def handles_file(self, path: str):
|
|
||||||
return self.file_identifier(path) if self.file_identifier else False
|
|
||||||
|
|
||||||
|
|
||||||
components: Iterable[Component] = (
|
|
||||||
# Launcher
|
|
||||||
Component('', 'Launcher'),
|
|
||||||
# Core
|
|
||||||
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
|
|
||||||
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
|
|
||||||
Component('Generate', 'Generate', cli=True),
|
|
||||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
|
||||||
# SNI
|
|
||||||
Component('SNI Client', 'SNIClient',
|
|
||||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')),
|
|
||||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
|
||||||
# Factorio
|
|
||||||
Component('Factorio Client', 'FactorioClient'),
|
|
||||||
# Minecraft
|
|
||||||
Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True,
|
|
||||||
file_identifier=SuffixIdentifier('.apmc')),
|
|
||||||
# Ocarina of Time
|
|
||||||
Component('OoT Client', 'OoTClient',
|
|
||||||
file_identifier=SuffixIdentifier('.apz5')),
|
|
||||||
Component('OoT Adjuster', 'OoTAdjuster'),
|
|
||||||
# FF1
|
|
||||||
Component('FF1 Client', 'FF1Client'),
|
|
||||||
# ChecksFinder
|
|
||||||
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
|
||||||
# Starcraft 2
|
|
||||||
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
|
||||||
# Functions
|
# Functions
|
||||||
Component('Open host.yaml', func=open_host_yaml),
|
Component("Open host.yaml", func=open_host_yaml),
|
||||||
Component('Open Patch', func=open_patch),
|
Component("Open Patch", func=open_patch),
|
||||||
Component('Browse Files', func=browse_files),
|
Component("Generate Template Settings", func=generate_yamls),
|
||||||
)
|
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
||||||
icon_paths = {
|
Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
||||||
'icon': local_path('data', 'icon.ico' if is_windows else 'icon.png'),
|
Component("Browse Files", func=browse_files),
|
||||||
'mcicon': local_path('data', 'mcicon.ico')
|
])
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def identify(path: Union[None, str]):
|
def identify(path: Union[None, str]):
|
||||||
if path is None:
|
if path is None:
|
||||||
return None, None, None
|
return None, None
|
||||||
for component in components:
|
for component in components:
|
||||||
if component.handles_file(path):
|
if component.handles_file(path):
|
||||||
return path, component.script_name, component
|
return path, component
|
||||||
return (None, None, None) if '/' in path or '\\' in path else (None, path, None)
|
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]]:
|
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
||||||
@@ -207,16 +150,18 @@ def launch(exe, in_terminal=False):
|
|||||||
|
|
||||||
def run_gui():
|
def run_gui():
|
||||||
from kvui import App, ContainerLayout, GridLayout, Button, Label
|
from kvui import App, ContainerLayout, GridLayout, Button, Label
|
||||||
|
from kivy.uix.image import AsyncImage
|
||||||
|
from kivy.uix.relativelayout import RelativeLayout
|
||||||
|
|
||||||
class Launcher(App):
|
class Launcher(App):
|
||||||
base_title: str = "Archipelago Launcher"
|
base_title: str = "Archipelago Launcher"
|
||||||
container: ContainerLayout
|
container: ContainerLayout
|
||||||
grid: GridLayout
|
grid: GridLayout
|
||||||
|
|
||||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL and isfile(get_exe(c)[-1])}
|
_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 and isfile(get_exe(c)[-1])}
|
_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 and isfile(get_exe(c)[-1])}
|
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
||||||
_funcs = {c.display_name: c for c in components if c.type == Type.FUNC}
|
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
||||||
|
|
||||||
def __init__(self, ctx=None):
|
def __init__(self, ctx=None):
|
||||||
self.title = self.base_title
|
self.title = self.base_title
|
||||||
@@ -228,24 +173,44 @@ def run_gui():
|
|||||||
self.container = ContainerLayout()
|
self.container = ContainerLayout()
|
||||||
self.grid = GridLayout(cols=2)
|
self.grid = GridLayout(cols=2)
|
||||||
self.container.add_widget(self.grid)
|
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
|
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(
|
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||||
self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()):
|
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
|
||||||
# column 1
|
# column 1
|
||||||
if tool:
|
if tool:
|
||||||
button = Button(text=tool[0])
|
build_button(tool[1])
|
||||||
button.component = tool[1]
|
|
||||||
button.bind(on_release=self.component_action)
|
|
||||||
button_layout.add_widget(button)
|
|
||||||
else:
|
else:
|
||||||
button_layout.add_widget(Label())
|
button_layout.add_widget(Label())
|
||||||
# column 2
|
# column 2
|
||||||
if client:
|
if client:
|
||||||
button = Button(text=client[0])
|
build_button(client[1])
|
||||||
button.component = client[1]
|
|
||||||
button.bind(on_press=self.component_action)
|
|
||||||
button_layout.add_widget(button)
|
|
||||||
else:
|
else:
|
||||||
button_layout.add_widget(Label())
|
button_layout.add_widget(Label())
|
||||||
|
|
||||||
@@ -253,14 +218,29 @@ def run_gui():
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def component_action(button):
|
def component_action(button):
|
||||||
if button.component.type == Type.FUNC:
|
if button.component.func:
|
||||||
button.component.func()
|
button.component.func()
|
||||||
else:
|
else:
|
||||||
launch(get_exe(button.component), button.component.cli)
|
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()
|
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):
|
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||||
if isinstance(args, argparse.Namespace):
|
if isinstance(args, argparse.Namespace):
|
||||||
args = {k: v for k, v in args._get_kwargs()}
|
args = {k: v for k, v in args._get_kwargs()}
|
||||||
@@ -268,24 +248,34 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
|||||||
args = {}
|
args = {}
|
||||||
|
|
||||||
if "Patch|Game|Component" in args:
|
if "Patch|Game|Component" in args:
|
||||||
file, component, _ = identify(args["Patch|Game|Component"])
|
file, component = identify(args["Patch|Game|Component"])
|
||||||
if file:
|
if file:
|
||||||
args['file'] = file
|
args['file'] = file
|
||||||
if component:
|
if component:
|
||||||
args['component'] = component
|
args['component'] = component
|
||||||
|
if not component:
|
||||||
|
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
|
||||||
|
|
||||||
if 'file' in args:
|
if 'file' in args:
|
||||||
subprocess.run([*get_exe(args['component']), args['file'], *args['args']])
|
run_component(args["component"], args["file"], *args["args"])
|
||||||
elif 'component' in args:
|
elif 'component' in args:
|
||||||
subprocess.run([*get_exe(args['component']), *args['args']])
|
run_component(args["component"], *args["args"])
|
||||||
else:
|
else:
|
||||||
run_gui()
|
run_gui()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
init_logging('Launcher')
|
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 = argparse.ArgumentParser(description='Archipelago Launcher')
|
||||||
parser.add_argument('Patch|Game|Component', type=str, nargs='?',
|
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.")
|
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.")
|
parser.add_argument('args', nargs="*", help="Arguments to pass to component.")
|
||||||
main(parser.parse_args())
|
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()
|
||||||
129
LttPAdjuster.py
129
LttPAdjuster.py
@@ -26,14 +26,16 @@ ModuleUpdate.update()
|
|||||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||||
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
||||||
get_adjuster_settings, tkinter_center_window, init_logging
|
get_adjuster_settings, tkinter_center_window, init_logging
|
||||||
from Patch import GAME_ALTTP
|
|
||||||
|
|
||||||
|
GAME_ALTTP = "A Link to the Past"
|
||||||
|
|
||||||
|
|
||||||
class AdjusterWorld(object):
|
class AdjusterWorld(object):
|
||||||
def __init__(self, sprite_pool):
|
def __init__(self, sprite_pool):
|
||||||
import random
|
import random
|
||||||
self.sprite_pool = {1: sprite_pool}
|
self.sprite_pool = {1: sprite_pool}
|
||||||
self.slot_seeds = {1: random}
|
self.per_slot_randoms = {1: random}
|
||||||
|
|
||||||
|
|
||||||
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||||
@@ -42,7 +44,7 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
|||||||
return textwrap.dedent(action.help)
|
return textwrap.dedent(action.help)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def get_argparser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
|
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
|
||||||
|
|
||||||
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
|
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
|
||||||
@@ -83,9 +85,6 @@ def main():
|
|||||||
parser.add_argument('--ow_palettes', default='default',
|
parser.add_argument('--ow_palettes', default='default',
|
||||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||||
'sick'])
|
'sick'])
|
||||||
parser.add_argument('--link_palettes', default='default',
|
|
||||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
|
||||||
'sick'])
|
|
||||||
parser.add_argument('--shield_palettes', default='default',
|
parser.add_argument('--shield_palettes', default='default',
|
||||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||||
'sick'])
|
'sick'])
|
||||||
@@ -105,8 +104,19 @@ def main():
|
|||||||
Alternatively, can be a ALttP Rom patched with a Link
|
Alternatively, can be a ALttP Rom patched with a Link
|
||||||
sprite that will be extracted.
|
sprite that will be extracted.
|
||||||
''')
|
''')
|
||||||
|
parser.add_argument('--oof', help='''\
|
||||||
|
Path to a sound effect to replace Link's "oof" sound.
|
||||||
|
Needs to be in a .brr format and have a length of no
|
||||||
|
more than 2673 bytes, created from a 16-bit signed PCM
|
||||||
|
.wav at 12khz. https://github.com/boldowa/snesbrr
|
||||||
|
''')
|
||||||
parser.add_argument('--names', default='', type=str)
|
parser.add_argument('--names', default='', type=str)
|
||||||
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
|
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = get_argparser()
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
args.music = not args.disablemusic
|
args.music = not args.disablemusic
|
||||||
# set up logger
|
# set up logger
|
||||||
@@ -124,6 +134,13 @@ def main():
|
|||||||
if args.sprite is not None and not os.path.isfile(args.sprite) and not Sprite.get_sprite_from_name(args.sprite):
|
if args.sprite is not None and not os.path.isfile(args.sprite) and not Sprite.get_sprite_from_name(args.sprite):
|
||||||
input('Could not find link sprite sheet at given location. \nPress Enter to exit.')
|
input('Could not find link sprite sheet at given location. \nPress Enter to exit.')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
if args.oof is not None and not os.path.isfile(args.oof):
|
||||||
|
input('Could not find oof sound effect at given location. \nPress Enter to exit.')
|
||||||
|
sys.exit(1)
|
||||||
|
if args.oof is not None and os.path.getsize(args.oof) > 2673:
|
||||||
|
input('"oof" sound effect cannot exceed 2673 bytes. \nPress Enter to exit.')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
args, path = adjust(args=args)
|
args, path = adjust(args=args)
|
||||||
if isinstance(args.sprite, Sprite):
|
if isinstance(args.sprite, Sprite):
|
||||||
@@ -139,7 +156,7 @@ def adjust(args):
|
|||||||
vanillaRom = args.baserom
|
vanillaRom = args.baserom
|
||||||
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
||||||
vanillaRom = local_path(vanillaRom)
|
vanillaRom = local_path(vanillaRom)
|
||||||
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
|
if os.path.splitext(args.rom)[-1].lower() == '.aplttp':
|
||||||
import Patch
|
import Patch
|
||||||
meta, args.rom = Patch.create_rom_file(args.rom)
|
meta, args.rom = Patch.create_rom_file(args.rom)
|
||||||
|
|
||||||
@@ -163,7 +180,7 @@ def adjust(args):
|
|||||||
world = getattr(args, "world")
|
world = getattr(args, "world")
|
||||||
|
|
||||||
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
|
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
|
||||||
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
|
args.sprite, args.oof, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
|
||||||
deathlink=args.deathlink, allowcollect=args.allowcollect)
|
deathlink=args.deathlink, allowcollect=args.allowcollect)
|
||||||
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
|
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
|
||||||
rom.write_to_file(path)
|
rom.write_to_file(path)
|
||||||
@@ -178,7 +195,7 @@ def adjustGUI():
|
|||||||
from tkinter import Tk, LEFT, BOTTOM, TOP, \
|
from tkinter import Tk, LEFT, BOTTOM, TOP, \
|
||||||
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
|
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from Main import __version__ as MWVersion
|
from Utils import __version__ as MWVersion
|
||||||
adjustWindow = Tk()
|
adjustWindow = Tk()
|
||||||
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
|
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
|
||||||
set_icon(adjustWindow)
|
set_icon(adjustWindow)
|
||||||
@@ -195,7 +212,7 @@ def adjustGUI():
|
|||||||
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
||||||
|
|
||||||
def RomSelect2():
|
def RomSelect2():
|
||||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".apbp")), ("All Files", "*")])
|
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".aplttp")), ("All Files", "*")])
|
||||||
romVar2.set(rom)
|
romVar2.set(rom)
|
||||||
|
|
||||||
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
||||||
@@ -225,6 +242,7 @@ def adjustGUI():
|
|||||||
guiargs.sprite = rom_vars.sprite
|
guiargs.sprite = rom_vars.sprite
|
||||||
if rom_vars.sprite_pool:
|
if rom_vars.sprite_pool:
|
||||||
guiargs.world = AdjusterWorld(rom_vars.sprite_pool)
|
guiargs.world = AdjusterWorld(rom_vars.sprite_pool)
|
||||||
|
guiargs.oof = rom_vars.oof
|
||||||
|
|
||||||
try:
|
try:
|
||||||
guiargs, path = adjust(args=guiargs)
|
guiargs, path = adjust(args=guiargs)
|
||||||
@@ -263,6 +281,7 @@ def adjustGUI():
|
|||||||
else:
|
else:
|
||||||
guiargs.sprite = rom_vars.sprite
|
guiargs.sprite = rom_vars.sprite
|
||||||
guiargs.sprite_pool = rom_vars.sprite_pool
|
guiargs.sprite_pool = rom_vars.sprite_pool
|
||||||
|
guiargs.oof = rom_vars.oof
|
||||||
persistent_store("adjuster", GAME_ALTTP, guiargs)
|
persistent_store("adjuster", GAME_ALTTP, guiargs)
|
||||||
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
|
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
|
||||||
|
|
||||||
@@ -479,6 +498,36 @@ class BackgroundTaskProgressNullWindow(BackgroundTask):
|
|||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class AttachTooltip(object):
|
||||||
|
|
||||||
|
def __init__(self, parent, text):
|
||||||
|
self._parent = parent
|
||||||
|
self._text = text
|
||||||
|
self._window = None
|
||||||
|
parent.bind('<Enter>', lambda event : self.show())
|
||||||
|
parent.bind('<Leave>', lambda event : self.hide())
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
if self._window or not self._text:
|
||||||
|
return
|
||||||
|
self._window = Toplevel(self._parent)
|
||||||
|
#remove window bar controls
|
||||||
|
self._window.wm_overrideredirect(1)
|
||||||
|
#adjust positioning
|
||||||
|
x, y, *_ = self._parent.bbox("insert")
|
||||||
|
x = x + self._parent.winfo_rootx() + 20
|
||||||
|
y = y + self._parent.winfo_rooty() + 20
|
||||||
|
self._window.wm_geometry("+{0}+{1}".format(x,y))
|
||||||
|
#show text
|
||||||
|
label = Label(self._window, text=self._text, justify=LEFT)
|
||||||
|
label.pack(ipadx=1)
|
||||||
|
|
||||||
|
def hide(self):
|
||||||
|
if self._window:
|
||||||
|
self._window.destroy()
|
||||||
|
self._window = None
|
||||||
|
|
||||||
|
|
||||||
def get_rom_frame(parent=None):
|
def get_rom_frame(parent=None):
|
||||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||||
if not adjuster_settings:
|
if not adjuster_settings:
|
||||||
@@ -520,6 +569,7 @@ def get_rom_options_frame(parent=None):
|
|||||||
"reduceflashing": True,
|
"reduceflashing": True,
|
||||||
"deathlink": False,
|
"deathlink": False,
|
||||||
"sprite": None,
|
"sprite": None,
|
||||||
|
"oof": None,
|
||||||
"quickswap": True,
|
"quickswap": True,
|
||||||
"menuspeed": 'normal',
|
"menuspeed": 'normal',
|
||||||
"heartcolor": 'red',
|
"heartcolor": 'red',
|
||||||
@@ -596,12 +646,50 @@ def get_rom_options_frame(parent=None):
|
|||||||
spriteEntry.pack(side=LEFT)
|
spriteEntry.pack(side=LEFT)
|
||||||
spriteSelectButton.pack(side=LEFT)
|
spriteSelectButton.pack(side=LEFT)
|
||||||
|
|
||||||
|
oofDialogFrame = Frame(romOptionsFrame)
|
||||||
|
oofDialogFrame.grid(row=1, column=1)
|
||||||
|
baseOofLabel = Label(oofDialogFrame, text='"OOF" Sound:')
|
||||||
|
|
||||||
|
vars.oofNameVar = StringVar()
|
||||||
|
vars.oof = adjuster_settings.oof
|
||||||
|
|
||||||
|
def set_oof(oof_param):
|
||||||
|
nonlocal vars
|
||||||
|
if isinstance(oof_param, str) and os.path.isfile(oof_param) and os.path.getsize(oof_param) <= 2673:
|
||||||
|
vars.oof = oof_param
|
||||||
|
vars.oofNameVar.set(oof_param.rsplit('/',1)[-1])
|
||||||
|
else:
|
||||||
|
vars.oof = None
|
||||||
|
vars.oofNameVar.set('(unchanged)')
|
||||||
|
|
||||||
|
set_oof(adjuster_settings.oof)
|
||||||
|
oofEntry = Label(oofDialogFrame, textvariable=vars.oofNameVar)
|
||||||
|
|
||||||
|
def OofSelect():
|
||||||
|
nonlocal vars
|
||||||
|
oof_file = filedialog.askopenfilename(
|
||||||
|
filetypes=[("BRR files", ".brr"),
|
||||||
|
("All Files", "*")])
|
||||||
|
try:
|
||||||
|
set_oof(oof_file)
|
||||||
|
except Exception:
|
||||||
|
set_oof(None)
|
||||||
|
|
||||||
|
oofSelectButton = Button(oofDialogFrame, text='...', command=OofSelect)
|
||||||
|
AttachTooltip(oofSelectButton,
|
||||||
|
text="Select a .brr file no more than 2673 bytes.\n" + \
|
||||||
|
"This can be created from a <=0.394s 16-bit signed PCM .wav file at 12khz using snesbrr.")
|
||||||
|
|
||||||
|
baseOofLabel.pack(side=LEFT)
|
||||||
|
oofEntry.pack(side=LEFT)
|
||||||
|
oofSelectButton.pack(side=LEFT)
|
||||||
|
|
||||||
vars.quickSwapVar = IntVar(value=adjuster_settings.quickswap)
|
vars.quickSwapVar = IntVar(value=adjuster_settings.quickswap)
|
||||||
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
|
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
|
||||||
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
|
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
|
||||||
|
|
||||||
menuspeedFrame = Frame(romOptionsFrame)
|
menuspeedFrame = Frame(romOptionsFrame)
|
||||||
menuspeedFrame.grid(row=1, column=1, sticky=E)
|
menuspeedFrame.grid(row=6, column=1, sticky=E)
|
||||||
menuspeedLabel = Label(menuspeedFrame, text='Menu speed')
|
menuspeedLabel = Label(menuspeedFrame, text='Menu speed')
|
||||||
menuspeedLabel.pack(side=LEFT)
|
menuspeedLabel.pack(side=LEFT)
|
||||||
vars.menuspeedVar = StringVar()
|
vars.menuspeedVar = StringVar()
|
||||||
@@ -725,7 +813,7 @@ def get_rom_options_frame(parent=None):
|
|||||||
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
|
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
|
||||||
autoApplyFrame = Frame(romOptionsFrame)
|
autoApplyFrame = Frame(romOptionsFrame)
|
||||||
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
||||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
|
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .aplttp files")
|
||||||
filler.pack(side=TOP, expand=True, fill=X)
|
filler.pack(side=TOP, expand=True, fill=X)
|
||||||
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
||||||
askRadio.pack(side=LEFT, padx=5, pady=5)
|
askRadio.pack(side=LEFT, padx=5, pady=5)
|
||||||
@@ -752,6 +840,7 @@ class SpriteSelector():
|
|||||||
self.window['pady'] = 5
|
self.window['pady'] = 5
|
||||||
self.spritesPerRow = 32
|
self.spritesPerRow = 32
|
||||||
self.all_sprites = []
|
self.all_sprites = []
|
||||||
|
self.invalid_sprites = []
|
||||||
self.sprite_pool = spritePool
|
self.sprite_pool = spritePool
|
||||||
|
|
||||||
def open_custom_sprite_dir(_evt):
|
def open_custom_sprite_dir(_evt):
|
||||||
@@ -833,6 +922,13 @@ class SpriteSelector():
|
|||||||
self.window.focus()
|
self.window.focus()
|
||||||
tkinter_center_window(self.window)
|
tkinter_center_window(self.window)
|
||||||
|
|
||||||
|
if self.invalid_sprites:
|
||||||
|
invalid = sorted(self.invalid_sprites)
|
||||||
|
logging.warning(f"The following sprites are invalid: {', '.join(invalid)}")
|
||||||
|
msg = f"{invalid[0]} "
|
||||||
|
msg += f"and {len(invalid)-1} more are invalid" if len(invalid) > 1 else "is invalid"
|
||||||
|
messagebox.showerror("Invalid sprites detected", msg, parent=self.window)
|
||||||
|
|
||||||
def remove_from_sprite_pool(self, button, spritename):
|
def remove_from_sprite_pool(self, button, spritename):
|
||||||
self.callback(("remove", spritename))
|
self.callback(("remove", spritename))
|
||||||
self.spritePoolButtons.buttons.remove(button)
|
self.spritePoolButtons.buttons.remove(button)
|
||||||
@@ -897,7 +993,13 @@ class SpriteSelector():
|
|||||||
sprites = []
|
sprites = []
|
||||||
|
|
||||||
for file in os.listdir(path):
|
for file in os.listdir(path):
|
||||||
sprites.append((file, Sprite(os.path.join(path, file))))
|
if file == '.gitignore':
|
||||||
|
continue
|
||||||
|
sprite = Sprite(os.path.join(path, file))
|
||||||
|
if sprite.valid:
|
||||||
|
sprites.append((file, sprite))
|
||||||
|
else:
|
||||||
|
self.invalid_sprites.append(file)
|
||||||
|
|
||||||
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())
|
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())
|
||||||
|
|
||||||
@@ -1040,7 +1142,6 @@ class SpriteSelector():
|
|||||||
def custom_sprite_dir(self):
|
def custom_sprite_dir(self):
|
||||||
return user_path("data", "sprites", "custom")
|
return user_path("data", "sprites", "custom")
|
||||||
|
|
||||||
|
|
||||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||||
if not sprite.valid:
|
if not sprite.valid:
|
||||||
return None
|
return None
|
||||||
|
|||||||
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()
|
||||||
383
Main.py
383
Main.py
@@ -1,23 +1,26 @@
|
|||||||
import collections
|
import collections
|
||||||
from itertools import zip_longest, chain
|
import concurrent.futures
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
import zlib
|
|
||||||
import concurrent.futures
|
|
||||||
import pickle
|
import pickle
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
from typing import Dict, Tuple, Optional, Set
|
import zlib
|
||||||
|
from typing import Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
import worlds
|
||||||
from worlds.alttp.Items import item_name_groups
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||||
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
|
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
|
||||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
from Options import StartInventoryPool
|
||||||
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
from Utils import __version__, get_options, output_path, version_tuple
|
||||||
from Utils import output_path, get_options, __version__, version_tuple
|
|
||||||
from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
|
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
|
from worlds.alttp.Regions import is_main_entrance
|
||||||
|
from worlds.alttp.Shops import FillDisabledShopSlots
|
||||||
|
from worlds.alttp.SubClasses import LTTPRegionType
|
||||||
|
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
ordered_areas = (
|
ordered_areas = (
|
||||||
'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
|
'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
|
||||||
@@ -38,7 +41,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
world = MultiWorld(args.multi)
|
world = MultiWorld(args.multi)
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
world.set_seed(seed, args.race, str(args.outputname if args.outputname else world.seed))
|
world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
||||||
|
world.plando_options = args.plando_options
|
||||||
|
|
||||||
world.shuffle = args.shuffle.copy()
|
world.shuffle = args.shuffle.copy()
|
||||||
world.logic = args.logic.copy()
|
world.logic = args.logic.copy()
|
||||||
@@ -52,7 +56,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
world.enemy_damage = args.enemy_damage.copy()
|
world.enemy_damage = args.enemy_damage.copy()
|
||||||
world.beemizer_total_chance = args.beemizer_total_chance.copy()
|
world.beemizer_total_chance = args.beemizer_total_chance.copy()
|
||||||
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
|
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
|
||||||
world.timer = args.timer.copy()
|
|
||||||
world.countdown_start_time = args.countdown_start_time.copy()
|
world.countdown_start_time = args.countdown_start_time.copy()
|
||||||
world.red_clock_time = args.red_clock_time.copy()
|
world.red_clock_time = args.red_clock_time.copy()
|
||||||
world.blue_clock_time = args.blue_clock_time.copy()
|
world.blue_clock_time = args.blue_clock_time.copy()
|
||||||
@@ -70,7 +73,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
world.required_medallions = args.required_medallions.copy()
|
world.required_medallions = args.required_medallions.copy()
|
||||||
world.game = args.game.copy()
|
world.game = args.game.copy()
|
||||||
world.player_name = args.name.copy()
|
world.player_name = args.name.copy()
|
||||||
world.enemizer = args.enemizercli
|
|
||||||
world.sprite = args.sprite.copy()
|
world.sprite = args.sprite.copy()
|
||||||
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
||||||
|
|
||||||
@@ -79,17 +81,32 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
world.state = CollectionState(world)
|
world.state = CollectionState(world)
|
||||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
|
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
|
||||||
|
|
||||||
logger.info("Found World Types:")
|
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
||||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||||
numlength = 8
|
|
||||||
|
max_item = 0
|
||||||
|
max_location = 0
|
||||||
|
for cls in AutoWorld.AutoWorldRegister.world_types.values():
|
||||||
|
if cls.item_id_to_name:
|
||||||
|
max_item = max(max_item, max(cls.item_id_to_name))
|
||||||
|
max_location = max(max_location, max(cls.location_id_to_name))
|
||||||
|
|
||||||
|
item_digits = len(str(max_item))
|
||||||
|
location_digits = len(str(max_location))
|
||||||
|
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
||||||
|
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
||||||
|
del max_item, max_location
|
||||||
|
|
||||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||||
if not cls.hidden:
|
if not cls.hidden and len(cls.item_names) > 0:
|
||||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
|
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
|
||||||
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
|
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
|
||||||
f"{max(cls.item_id_to_name):{numlength}}) | "
|
f"{max(cls.item_id_to_name):{item_digits}}) | "
|
||||||
f"{len(cls.location_names):3} "
|
f"{len(cls.location_names):{location_count}} "
|
||||||
f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
|
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
|
||||||
f"{max(cls.location_id_to_name):{numlength}})")
|
f"{max(cls.location_id_to_name):{location_digits}})")
|
||||||
|
|
||||||
|
del item_digits, location_digits, item_count, location_count
|
||||||
|
|
||||||
AutoWorld.call_stage(world, "assert_generate")
|
AutoWorld.call_stage(world, "assert_generate")
|
||||||
|
|
||||||
@@ -102,18 +119,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
world.push_precollected(world.create_item(item_name, player))
|
world.push_precollected(world.create_item(item_name, player))
|
||||||
|
|
||||||
for player in world.player_ids:
|
for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
|
||||||
if player in world.get_game_players("A Link to the Past"):
|
for _ in range(count):
|
||||||
# enforce pre-defined local items.
|
world.push_precollected(world.create_item(item_name, player))
|
||||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
|
||||||
world.local_items[player].value.add('Triforce Piece')
|
|
||||||
|
|
||||||
# Not possible to place pendants/crystals out side of boss prizes yet.
|
|
||||||
world.non_local_items[player].value -= item_name_groups['Pendants']
|
|
||||||
world.non_local_items[player].value -= item_name_groups['Crystals']
|
|
||||||
|
|
||||||
# items can't be both local and non-local, prefer local
|
|
||||||
world.non_local_items[player].value -= world.local_items[player].value
|
|
||||||
|
|
||||||
logger.info('Creating World.')
|
logger.info('Creating World.')
|
||||||
AutoWorld.call_all(world, "create_regions")
|
AutoWorld.call_all(world, "create_regions")
|
||||||
@@ -121,11 +129,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
logger.info('Creating Items.')
|
logger.info('Creating Items.')
|
||||||
AutoWorld.call_all(world, "create_items")
|
AutoWorld.call_all(world, "create_items")
|
||||||
|
|
||||||
|
# All worlds should have finished creating all regions, locations, and entrances.
|
||||||
|
# Recache to ensure that they are all visible for locality rules.
|
||||||
|
world._recache()
|
||||||
|
|
||||||
logger.info('Calculating Access Rules.')
|
logger.info('Calculating Access Rules.')
|
||||||
|
|
||||||
|
for player in world.player_ids:
|
||||||
|
# items can't be both local and non-local, prefer local
|
||||||
|
world.non_local_items[player].value -= world.local_items[player].value
|
||||||
|
world.non_local_items[player].value -= set(world.local_early_items[player])
|
||||||
|
|
||||||
if world.players > 1:
|
if world.players > 1:
|
||||||
for player in world.player_ids:
|
locality_rules(world)
|
||||||
locality_rules(world, player)
|
|
||||||
group_locality_rules(world)
|
|
||||||
else:
|
else:
|
||||||
world.non_local_items[1].value = set()
|
world.non_local_items[1].value = set()
|
||||||
world.local_items[1].value = set()
|
world.local_items[1].value = set()
|
||||||
@@ -140,10 +156,43 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
AutoWorld.call_all(world, "generate_basic")
|
AutoWorld.call_all(world, "generate_basic")
|
||||||
|
|
||||||
|
# remove starting inventory from pool items.
|
||||||
|
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||||
|
if any(world.start_inventory_from_pool[player].value for player in world.player_ids):
|
||||||
|
new_items: List[Item] = []
|
||||||
|
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||||
|
player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids}
|
||||||
|
for player, items in depletion_pool.items():
|
||||||
|
player_world: AutoWorld.World = world.worlds[player]
|
||||||
|
for count in items.values():
|
||||||
|
new_items.append(player_world.create_filler())
|
||||||
|
target: int = sum(sum(items.values()) for items in depletion_pool.values())
|
||||||
|
for i, item in enumerate(world.itempool):
|
||||||
|
if depletion_pool[item.player].get(item.name, 0):
|
||||||
|
target -= 1
|
||||||
|
depletion_pool[item.player][item.name] -= 1
|
||||||
|
# quick abort if we have found all items
|
||||||
|
if not target:
|
||||||
|
new_items.extend(world.itempool[i+1:])
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
new_items.append(item)
|
||||||
|
|
||||||
|
# leftovers?
|
||||||
|
if target:
|
||||||
|
for player, remaining_items in depletion_pool.items():
|
||||||
|
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
||||||
|
if remaining_items:
|
||||||
|
raise Exception(f"{world.get_player_name(player)}"
|
||||||
|
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
||||||
|
world.itempool[:] = new_items
|
||||||
|
|
||||||
# temporary home for item links, should be moved out of Main
|
# temporary home for item links, should be moved out of Main
|
||||||
for group_id, group in world.groups.items():
|
for group_id, group in world.groups.items():
|
||||||
def find_common_pool(players: Set[int], shared_pool: Set[str]):
|
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
||||||
classifications = collections.defaultdict(int)
|
Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
|
||||||
|
]:
|
||||||
|
classifications: Dict[str, int] = collections.defaultdict(int)
|
||||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||||
for item in world.itempool:
|
for item in world.itempool:
|
||||||
if item.player in counters and item.name in shared_pool:
|
if item.player in counters and item.name in shared_pool:
|
||||||
@@ -153,7 +202,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player in players.copy():
|
for player in players.copy():
|
||||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||||
players.remove(player)
|
players.remove(player)
|
||||||
del(counters[player])
|
del (counters[player])
|
||||||
|
|
||||||
if not players:
|
if not players:
|
||||||
return None, None
|
return None, None
|
||||||
@@ -165,14 +214,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
counters[player][item] = count
|
counters[player][item] = count
|
||||||
else:
|
else:
|
||||||
for player in players:
|
for player in players:
|
||||||
del(counters[player][item])
|
del (counters[player][item])
|
||||||
return counters, classifications
|
return counters, classifications
|
||||||
|
|
||||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||||
if not common_item_count:
|
if not common_item_count:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
new_itempool = []
|
new_itempool: List[Item] = []
|
||||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||||
for _ in range(item_count):
|
for _ in range(item_count):
|
||||||
new_item = group["world"].create_item(item_name)
|
new_item = group["world"].create_item(item_name)
|
||||||
@@ -180,7 +229,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
new_item.classification |= classifications[item_name]
|
new_item.classification |= classifications[item_name]
|
||||||
new_itempool.append(new_item)
|
new_itempool.append(new_item)
|
||||||
|
|
||||||
region = Region("Menu", RegionType.Generic, "ItemLink", group_id, world)
|
region = Region("Menu", group_id, world, "ItemLink")
|
||||||
world.regions.append(region)
|
world.regions.append(region)
|
||||||
locations = region.locations = []
|
locations = region.locations = []
|
||||||
for item in world.itempool:
|
for item in world.itempool:
|
||||||
@@ -203,11 +252,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
while itemcount > len(world.itempool):
|
while itemcount > len(world.itempool):
|
||||||
items_to_add = []
|
items_to_add = []
|
||||||
for player in group["players"]:
|
for player in group["players"]:
|
||||||
|
if group["link_replacement"]:
|
||||||
|
item_player = group_id
|
||||||
|
else:
|
||||||
|
item_player = player
|
||||||
if group["replacement_items"][player]:
|
if group["replacement_items"][player]:
|
||||||
items_to_add.append(AutoWorld.call_single(world, "create_item", player,
|
items_to_add.append(AutoWorld.call_single(world, "create_item", item_player,
|
||||||
group["replacement_items"][player]))
|
group["replacement_items"][player]))
|
||||||
else:
|
else:
|
||||||
items_to_add.append(AutoWorld.call_single(world, "create_filler", player))
|
items_to_add.append(AutoWorld.call_single(world, "create_filler", item_player))
|
||||||
world.random.shuffle(items_to_add)
|
world.random.shuffle(items_to_add)
|
||||||
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
|
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
|
||||||
|
|
||||||
@@ -232,10 +285,16 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
AutoWorld.call_all(world, 'post_fill')
|
AutoWorld.call_all(world, 'post_fill')
|
||||||
|
|
||||||
if world.players > 1:
|
if world.players > 1 and not args.skip_prog_balancing:
|
||||||
balance_multiworld_progression(world)
|
balance_multiworld_progression(world)
|
||||||
|
else:
|
||||||
|
logger.info("Progression balancing skipped.")
|
||||||
|
|
||||||
logger.info(f'Beginning output...')
|
logger.info(f'Beginning output...')
|
||||||
|
|
||||||
|
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
|
||||||
|
world.random.passthrough = False
|
||||||
|
|
||||||
outfilebase = 'AP_' + world.seed_name
|
outfilebase = 'AP_' + world.seed_name
|
||||||
|
|
||||||
output = tempfile.TemporaryDirectory()
|
output = tempfile.TemporaryDirectory()
|
||||||
@@ -250,24 +309,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
output_file_futures.append(
|
output_file_futures.append(
|
||||||
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
||||||
|
|
||||||
def get_entrance_to_region(region: Region):
|
|
||||||
for entrance in region.entrances:
|
|
||||||
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
|
|
||||||
return entrance
|
|
||||||
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
|
|
||||||
return get_entrance_to_region(entrance.parent_region)
|
|
||||||
|
|
||||||
# collect ER hint info
|
# collect ER hint info
|
||||||
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
|
er_hint_data: Dict[int, Dict[int, str]] = {}
|
||||||
world.shuffle[player] != "vanilla" or world.retro_caves[player]}
|
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
|
||||||
|
|
||||||
for region in world.regions:
|
|
||||||
if region.player in er_hint_data and region.locations:
|
|
||||||
main_entrance = get_entrance_to_region(region)
|
|
||||||
for location in region.locations:
|
|
||||||
if type(location.address) == int: # skips events and crystals
|
|
||||||
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
|
|
||||||
er_hint_data[region.player][location.address] = main_entrance.name
|
|
||||||
|
|
||||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||||
for player in range(1, world.players + 1)}
|
for player in range(1, world.players + 1)}
|
||||||
@@ -277,44 +321,24 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
for location in world.get_filled_locations():
|
for location in world.get_filled_locations():
|
||||||
if type(location.address) is int:
|
if type(location.address) is int:
|
||||||
main_entrance = get_entrance_to_region(location.parent_region)
|
|
||||||
if location.game != "A Link to the Past":
|
if location.game != "A Link to the Past":
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
elif location.parent_region.dungeon:
|
|
||||||
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
|
||||||
'Inverted Ganons Tower': 'Ganons Tower'} \
|
|
||||||
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
|
||||||
checks_in_area[location.player][dungeonname].append(location.address)
|
|
||||||
elif location.parent_region.type == RegionType.LightWorld:
|
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
|
||||||
elif location.parent_region.type == RegionType.DarkWorld:
|
|
||||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
|
||||||
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
|
||||||
checks_in_area[location.player]["Light World"].append(location.address)
|
|
||||||
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
|
||||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
|
||||||
checks_in_area[location.player]["Total"] += 1
|
|
||||||
|
|
||||||
oldmancaves = []
|
|
||||||
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
|
|
||||||
for index, take_any in enumerate(takeanyregions):
|
|
||||||
for region in [world.get_region(take_any, player) for player in
|
|
||||||
world.get_game_players("A Link to the Past") if world.retro_caves[player]]:
|
|
||||||
item = world.create_item(
|
|
||||||
region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
|
|
||||||
region.player)
|
|
||||||
player = region.player
|
|
||||||
location_id = SHOP_ID_START + total_shop_slots + index
|
|
||||||
|
|
||||||
main_entrance = get_entrance_to_region(region)
|
|
||||||
if main_entrance.parent_region.type == RegionType.LightWorld:
|
|
||||||
checks_in_area[player]["Light World"].append(location_id)
|
|
||||||
else:
|
else:
|
||||||
checks_in_area[player]["Dark World"].append(location_id)
|
main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance)
|
||||||
checks_in_area[player]["Total"] += 1
|
if location.parent_region.dungeon:
|
||||||
|
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
||||||
er_hint_data[player][location_id] = main_entrance.name
|
'Inverted Ganons Tower': 'Ganons Tower'} \
|
||||||
oldmancaves.append(((location_id, player), (item.code, player)))
|
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||||
|
checks_in_area[location.player][dungeonname].append(location.address)
|
||||||
|
elif location.parent_region.type == LTTPRegionType.LightWorld:
|
||||||
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
|
elif location.parent_region.type == LTTPRegionType.DarkWorld:
|
||||||
|
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||||
|
elif main_entrance.parent_region.type == LTTPRegionType.LightWorld:
|
||||||
|
checks_in_area[location.player]["Light World"].append(location.address)
|
||||||
|
elif main_entrance.parent_region.type == LTTPRegionType.DarkWorld:
|
||||||
|
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||||
|
checks_in_area[location.player]["Total"] += 1
|
||||||
|
|
||||||
FillDisabledShopSlots(world)
|
FillDisabledShopSlots(world)
|
||||||
|
|
||||||
@@ -341,7 +365,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player, world_precollected in world.precollected_items.items()}
|
for player, world_precollected in world.precollected_items.items()}
|
||||||
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
|
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
|
||||||
|
|
||||||
|
|
||||||
for slot in world.player_ids:
|
for slot in world.player_ids:
|
||||||
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||||
|
|
||||||
@@ -372,16 +395,17 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player in world.groups.get(location.item.player, {}).get("players", [])]):
|
for player in world.groups.get(location.item.player, {}).get("players", [])]):
|
||||||
precollect_hint(location)
|
precollect_hint(location)
|
||||||
|
|
||||||
|
# embedded data package
|
||||||
|
data_package = {
|
||||||
|
game_world.game: worlds.network_data_package["games"][game_world.game]
|
||||||
|
for game_world in world.worlds.values()
|
||||||
|
}
|
||||||
|
|
||||||
multidata = {
|
multidata = {
|
||||||
"slot_data": slot_data,
|
"slot_data": slot_data,
|
||||||
"slot_info": slot_info,
|
"slot_info": slot_info,
|
||||||
"names": names, # TODO: remove around 0.2.5 in favor of slot_info
|
"names": names, # TODO: remove after 0.3.9
|
||||||
"games": games, # TODO: remove around 0.2.5 in favor of slot_info
|
|
||||||
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
|
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
|
||||||
"remote_items": {player for player in world.player_ids if
|
|
||||||
world.worlds[player].remote_items},
|
|
||||||
"remote_start_inventory": {player for player in world.player_ids if
|
|
||||||
world.worlds[player].remote_start_inventory},
|
|
||||||
"locations": locations_data,
|
"locations": locations_data,
|
||||||
"checks_in_area": checks_in_area,
|
"checks_in_area": checks_in_area,
|
||||||
"server_options": baked_server_options,
|
"server_options": baked_server_options,
|
||||||
@@ -391,7 +415,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
"version": tuple(version_tuple),
|
"version": tuple(version_tuple),
|
||||||
"tags": ["AP"],
|
"tags": ["AP"],
|
||||||
"minimum_versions": minimum_versions,
|
"minimum_versions": minimum_versions,
|
||||||
"seed_name": world.seed_name
|
"seed_name": world.seed_name,
|
||||||
|
"datapackage": data_package,
|
||||||
}
|
}
|
||||||
AutoWorld.call_all(world, "modify_multidata", multidata)
|
AutoWorld.call_all(world, "modify_multidata", multidata)
|
||||||
|
|
||||||
@@ -417,7 +442,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
if args.spoiler > 1:
|
if args.spoiler > 1:
|
||||||
logger.info('Calculating playthrough.')
|
logger.info('Calculating playthrough.')
|
||||||
create_playthrough(world)
|
world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
||||||
|
|
||||||
if args.spoiler:
|
if args.spoiler:
|
||||||
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
||||||
@@ -431,143 +456,3 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
|
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
|
||||||
return world
|
return world
|
||||||
|
|
||||||
|
|
||||||
def create_playthrough(world):
|
|
||||||
"""Destructive to the world while it is run, damage gets repaired afterwards."""
|
|
||||||
# get locations containing progress items
|
|
||||||
prog_locations = {location for location in world.get_filled_locations() if location.item.advancement}
|
|
||||||
state_cache = [None]
|
|
||||||
collection_spheres = []
|
|
||||||
state = CollectionState(world)
|
|
||||||
sphere_candidates = set(prog_locations)
|
|
||||||
logging.debug('Building up collection spheres.')
|
|
||||||
while sphere_candidates:
|
|
||||||
|
|
||||||
# build up spheres of collection radius.
|
|
||||||
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
|
||||||
|
|
||||||
sphere = {location for location in sphere_candidates if state.can_reach(location)}
|
|
||||||
|
|
||||||
for location in sphere:
|
|
||||||
state.collect(location.item, True, location)
|
|
||||||
|
|
||||||
sphere_candidates -= sphere
|
|
||||||
collection_spheres.append(sphere)
|
|
||||||
state_cache.append(state.copy())
|
|
||||||
|
|
||||||
logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere),
|
|
||||||
len(prog_locations))
|
|
||||||
if not sphere:
|
|
||||||
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
|
||||||
location.item.name, location.item.player, location.name, location.player) for location in
|
|
||||||
sphere_candidates])
|
|
||||||
if any([world.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
|
|
||||||
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
|
||||||
f'Something went terribly wrong here.')
|
|
||||||
else:
|
|
||||||
world.spoiler.unreachables = sphere_candidates
|
|
||||||
break
|
|
||||||
|
|
||||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
|
||||||
# reducing each range of influence to the bare minimum required inside it
|
|
||||||
restore_later = {}
|
|
||||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
|
||||||
to_delete = set()
|
|
||||||
for location in sphere:
|
|
||||||
# we remove the item at location and check if game is still beatable
|
|
||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
|
||||||
location.item.player)
|
|
||||||
old_item = location.item
|
|
||||||
location.item = None
|
|
||||||
if world.can_beat_game(state_cache[num]):
|
|
||||||
to_delete.add(location)
|
|
||||||
restore_later[location] = old_item
|
|
||||||
else:
|
|
||||||
# still required, got to keep it around
|
|
||||||
location.item = old_item
|
|
||||||
|
|
||||||
# cull entries in spheres for spoiler walkthrough at end
|
|
||||||
sphere -= to_delete
|
|
||||||
|
|
||||||
# second phase, sphere 0
|
|
||||||
removed_precollected = []
|
|
||||||
for item in (i for i in chain.from_iterable(world.precollected_items.values()) if i.advancement):
|
|
||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
|
||||||
world.precollected_items[item.player].remove(item)
|
|
||||||
world.state.remove(item)
|
|
||||||
if not world.can_beat_game():
|
|
||||||
world.push_precollected(item)
|
|
||||||
else:
|
|
||||||
removed_precollected.append(item)
|
|
||||||
|
|
||||||
# we are now down to just the required progress items in collection_spheres. Unfortunately
|
|
||||||
# the previous pruning stage could potentially have made certain items dependant on others
|
|
||||||
# in the same or later sphere (because the location had 2 ways to access but the item originally
|
|
||||||
# used to access it was deemed not required.) So we need to do one final sphere collection pass
|
|
||||||
# to build up the correct spheres
|
|
||||||
|
|
||||||
required_locations = {item for sphere in collection_spheres for item in sphere}
|
|
||||||
state = CollectionState(world)
|
|
||||||
collection_spheres = []
|
|
||||||
while required_locations:
|
|
||||||
state.sweep_for_events(key_only=True)
|
|
||||||
|
|
||||||
sphere = set(filter(state.can_reach, required_locations))
|
|
||||||
|
|
||||||
for location in sphere:
|
|
||||||
state.collect(location.item, True, location)
|
|
||||||
|
|
||||||
required_locations -= sphere
|
|
||||||
|
|
||||||
collection_spheres.append(sphere)
|
|
||||||
|
|
||||||
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
|
|
||||||
len(sphere), len(required_locations))
|
|
||||||
if not sphere:
|
|
||||||
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
|
|
||||||
|
|
||||||
def flist_to_iter(node):
|
|
||||||
while node:
|
|
||||||
value, node = node
|
|
||||||
yield value
|
|
||||||
|
|
||||||
def get_path(state, region):
|
|
||||||
reversed_path_as_flist = state.path.get(region, (region, None))
|
|
||||||
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
|
|
||||||
# Now we combine the flat string list into (region, exit) pairs
|
|
||||||
pathsiter = iter(string_path_flat)
|
|
||||||
pathpairs = zip_longest(pathsiter, pathsiter)
|
|
||||||
return list(pathpairs)
|
|
||||||
|
|
||||||
world.spoiler.paths = {}
|
|
||||||
topology_worlds = (player for player in world.player_ids if world.worlds[player].topology_present)
|
|
||||||
for player in topology_worlds:
|
|
||||||
world.spoiler.paths.update(
|
|
||||||
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
|
|
||||||
sphere if location.player == player})
|
|
||||||
if player in world.get_game_players("A Link to the Past"):
|
|
||||||
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
|
|
||||||
# Maybe move the big bomb over to the Event system instead?
|
|
||||||
if any(exit_path == 'Pyramid Fairy' for path in world.spoiler.paths.values() for (_, exit_path) in path):
|
|
||||||
if world.mode[player] != 'inverted':
|
|
||||||
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = \
|
|
||||||
get_path(state, world.get_region('Big Bomb Shop', player))
|
|
||||||
else:
|
|
||||||
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = \
|
|
||||||
get_path(state, world.get_region('Inverted Big Bomb Shop', player))
|
|
||||||
|
|
||||||
# we can finally output our playthrough
|
|
||||||
world.spoiler.playthrough = {"0": sorted([str(item) for item in
|
|
||||||
chain.from_iterable(world.precollected_items.values())
|
|
||||||
if item.advancement])}
|
|
||||||
|
|
||||||
for i, sphere in enumerate(collection_spheres):
|
|
||||||
world.spoiler.playthrough[str(i + 1)] = {str(location): str(location.item) for location in sorted(sphere)}
|
|
||||||
|
|
||||||
# repair the world again
|
|
||||||
for location, item in restore_later.items():
|
|
||||||
location.item = item
|
|
||||||
|
|
||||||
for item in removed_precollected:
|
|
||||||
world.push_precollected(item)
|
|
||||||
|
|||||||
@@ -77,49 +77,34 @@ def read_apmc_file(apmc_file):
|
|||||||
return json.loads(b64decode(f.read()))
|
return json.loads(b64decode(f.read()))
|
||||||
|
|
||||||
|
|
||||||
def update_mod(forge_dir, minecraft_version: str, get_prereleases=False):
|
def update_mod(forge_dir, url: str):
|
||||||
"""Check mod version, download new mod from GitHub releases page if needed. """
|
"""Check mod version, download new mod from GitHub releases page if needed. """
|
||||||
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
||||||
|
os.path.basename(url)
|
||||||
client_releases_endpoint = "https://api.github.com/repos/KonoTyran/Minecraft_AP_Randomizer/releases"
|
if ap_randomizer is not None:
|
||||||
resp = requests.get(client_releases_endpoint)
|
logging.info(f"Your current mod is {ap_randomizer}.")
|
||||||
if resp.status_code == 200: # OK
|
|
||||||
try:
|
|
||||||
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
|
|
||||||
(minecraft_version in release['assets'][0]['name']),
|
|
||||||
resp.json()))
|
|
||||||
if ap_randomizer != latest_release['assets'][0]['name']:
|
|
||||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
|
||||||
f"{latest_release['assets'][0]['name']}")
|
|
||||||
if ap_randomizer is not None:
|
|
||||||
logging.info(f"Your current mod is {ap_randomizer}.")
|
|
||||||
else:
|
|
||||||
logging.info(f"You do not have the AP randomizer mod installed.")
|
|
||||||
if prompt_yes_no("Would you like to update?"):
|
|
||||||
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
|
|
||||||
new_ap_mod = os.path.join(forge_dir, 'mods', latest_release['assets'][0]['name'])
|
|
||||||
logging.info("Downloading AP randomizer mod. This may take a moment...")
|
|
||||||
apmod_resp = requests.get(latest_release['assets'][0]['browser_download_url'])
|
|
||||||
if apmod_resp.status_code == 200:
|
|
||||||
with open(new_ap_mod, 'wb') as f:
|
|
||||||
f.write(apmod_resp.content)
|
|
||||||
logging.info(f"Wrote new mod file to {new_ap_mod}")
|
|
||||||
if old_ap_mod is not None:
|
|
||||||
os.remove(old_ap_mod)
|
|
||||||
logging.info(f"Removed old mod file from {old_ap_mod}")
|
|
||||||
else:
|
|
||||||
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
|
||||||
logging.error(f"Please report this issue on the Archipelago Discord server.")
|
|
||||||
sys.exit(1)
|
|
||||||
except StopIteration:
|
|
||||||
logging.warning(f"No compatible mod version found for {minecraft_version}.")
|
|
||||||
if not prompt_yes_no("Run server anyway?"):
|
|
||||||
sys.exit(0)
|
|
||||||
else:
|
else:
|
||||||
logging.error(f"Error checking for randomizer mod updates (status code {resp.status_code}).")
|
logging.info(f"You do not have the AP randomizer mod installed.")
|
||||||
logging.error(f"If this was not expected, please report this issue on the Archipelago Discord server.")
|
|
||||||
if not prompt_yes_no("Continue anyways?"):
|
if ap_randomizer != os.path.basename(url):
|
||||||
sys.exit(0)
|
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):
|
def check_eula(forge_dir):
|
||||||
@@ -264,8 +249,13 @@ def get_minecraft_versions(version, release_channel="release"):
|
|||||||
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
||||||
else:
|
else:
|
||||||
return resp.json()[release_channel][0]
|
return resp.json()[release_channel][0]
|
||||||
except StopIteration:
|
except (StopIteration, KeyError):
|
||||||
logging.error(f"No compatible mod version found for client version {version}.")
|
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:
|
def is_correct_forge(forge_dir) -> bool:
|
||||||
@@ -286,6 +276,8 @@ if __name__ == '__main__':
|
|||||||
help="specify java version.")
|
help="specify java version.")
|
||||||
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
|
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)")
|
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()
|
args = parser.parse_args()
|
||||||
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
|
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
|
||||||
@@ -296,12 +288,12 @@ if __name__ == '__main__':
|
|||||||
options = Utils.get_options()
|
options = Utils.get_options()
|
||||||
channel = args.channel or options["minecraft_options"]["release_channel"]
|
channel = args.channel or options["minecraft_options"]["release_channel"]
|
||||||
apmc_data = None
|
apmc_data = None
|
||||||
data_version = None
|
data_version = args.data_version or None
|
||||||
|
|
||||||
if apmc_file is None and not args.install:
|
if apmc_file is None and not args.install:
|
||||||
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
|
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
|
||||||
|
|
||||||
if apmc_file is not None:
|
if apmc_file is not None and data_version is None:
|
||||||
apmc_data = read_apmc_file(apmc_file)
|
apmc_data = read_apmc_file(apmc_file)
|
||||||
data_version = apmc_data.get('client_version', '')
|
data_version = apmc_data.get('client_version', '')
|
||||||
|
|
||||||
@@ -311,6 +303,7 @@ if __name__ == '__main__':
|
|||||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
max_heap = options["minecraft_options"]["max_heap_size"]
|
||||||
forge_version = args.forge or versions["forge"]
|
forge_version = args.forge or versions["forge"]
|
||||||
java_version = args.java or versions["java"]
|
java_version = args.java or versions["java"]
|
||||||
|
mod_url = versions["url"]
|
||||||
java_dir = find_jdk_dir(java_version)
|
java_dir = find_jdk_dir(java_version)
|
||||||
|
|
||||||
if args.install:
|
if args.install:
|
||||||
@@ -344,7 +337,7 @@ if __name__ == '__main__':
|
|||||||
if not max_heap_re.match(max_heap):
|
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.")
|
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, f"MC{forge_version.split('-')[0]}", channel != "release")
|
update_mod(forge_dir, mod_url)
|
||||||
replace_apmc_files(forge_dir, apmc_file)
|
replace_apmc_files(forge_dir, apmc_file)
|
||||||
check_eula(forge_dir)
|
check_eula(forge_dir)
|
||||||
server_process = run_forge_server(forge_dir, java_version, max_heap)
|
server_process = run_forge_server(forge_dir, java_version, max_heap)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import pkg_resources
|
import multiprocessing
|
||||||
|
import warnings
|
||||||
|
|
||||||
local_dir = os.path.dirname(__file__)
|
local_dir = os.path.dirname(__file__)
|
||||||
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
|
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
|
||||||
@@ -9,49 +10,109 @@ requirements_files = {os.path.join(local_dir, 'requirements.txt')}
|
|||||||
if sys.version_info < (3, 8, 6):
|
if sys.version_info < (3, 8, 6):
|
||||||
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
||||||
|
|
||||||
update_ran = getattr(sys, "frozen", False) # 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:
|
if not update_ran:
|
||||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||||
if entry.is_dir():
|
# skip .* (hidden / disabled) folders
|
||||||
req_file = os.path.join(entry.path, "requirements.txt")
|
if not entry.name.startswith("."):
|
||||||
if os.path.exists(req_file):
|
if entry.is_dir():
|
||||||
requirements_files.add(req_file)
|
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():
|
def update_command():
|
||||||
|
check_pip()
|
||||||
for file in requirements_files:
|
for file in requirements_files:
|
||||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade'])
|
subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--upgrade"])
|
||||||
|
|
||||||
|
|
||||||
|
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(yes=False, force=False):
|
def update(yes=False, force=False):
|
||||||
global update_ran
|
global update_ran
|
||||||
if not update_ran:
|
if not update_ran:
|
||||||
update_ran = True
|
update_ran = True
|
||||||
|
|
||||||
if force:
|
if force:
|
||||||
update_command()
|
update_command()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
install_pkg_resources(yes=yes)
|
||||||
|
import pkg_resources
|
||||||
|
|
||||||
for req_file in requirements_files:
|
for req_file in requirements_files:
|
||||||
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
path = os.path.join(os.path.dirname(__file__), req_file)
|
path = os.path.join(os.path.dirname(__file__), req_file)
|
||||||
with open(path) as requirementsfile:
|
with open(path) as requirementsfile:
|
||||||
for line in requirementsfile:
|
for line in requirementsfile:
|
||||||
if line.startswith('https://'):
|
if not line or line[0] == "#":
|
||||||
# extract name and version from url
|
continue # ignore comments
|
||||||
wheel = line.split('/')[-1]
|
if line.startswith(("https://", "git+https://")):
|
||||||
name, version, _ = wheel.split('-', 2)
|
# extract name and version for url
|
||||||
line = f'{name}=={version}'
|
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)
|
requirements = pkg_resources.parse_requirements(line)
|
||||||
for requirement in requirements:
|
for requirement in map(str, requirements):
|
||||||
requirement = str(requirement)
|
|
||||||
try:
|
try:
|
||||||
pkg_resources.require(requirement)
|
pkg_resources.require(requirement)
|
||||||
except pkg_resources.ResolutionError:
|
except pkg_resources.ResolutionError:
|
||||||
if not yes:
|
if not yes:
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
input(f'Requirement {requirement} is not satisfied, press enter to install it')
|
confirm(f"Requirement {requirement} is not satisfied, press enter to install it")
|
||||||
update_command()
|
update_command()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
920
MultiServer.py
920
MultiServer.py
File diff suppressed because it is too large
Load Diff
20
NetUtils.py
20
NetUtils.py
@@ -6,7 +6,7 @@ from json import JSONEncoder, JSONDecoder
|
|||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
from Utils import Version
|
from Utils import ByValue, Version
|
||||||
|
|
||||||
|
|
||||||
class JSONMessagePart(typing.TypedDict, total=False):
|
class JSONMessagePart(typing.TypedDict, total=False):
|
||||||
@@ -20,7 +20,7 @@ class JSONMessagePart(typing.TypedDict, total=False):
|
|||||||
flags: int
|
flags: int
|
||||||
|
|
||||||
|
|
||||||
class ClientStatus(enum.IntEnum):
|
class ClientStatus(ByValue, enum.IntEnum):
|
||||||
CLIENT_UNKNOWN = 0
|
CLIENT_UNKNOWN = 0
|
||||||
CLIENT_CONNECTED = 5
|
CLIENT_CONNECTED = 5
|
||||||
CLIENT_READY = 10
|
CLIENT_READY = 10
|
||||||
@@ -28,22 +28,22 @@ class ClientStatus(enum.IntEnum):
|
|||||||
CLIENT_GOAL = 30
|
CLIENT_GOAL = 30
|
||||||
|
|
||||||
|
|
||||||
class SlotType(enum.IntFlag):
|
class SlotType(ByValue, enum.IntFlag):
|
||||||
spectator = 0b00
|
spectator = 0b00
|
||||||
player = 0b01
|
player = 0b01
|
||||||
group = 0b10
|
group = 0b10
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def always_goal(self) -> bool:
|
def always_goal(self) -> bool:
|
||||||
"""Mark this slot has having reached its goal instantly."""
|
"""Mark this slot as having reached its goal instantly."""
|
||||||
return self.value != 0b01
|
return self.value != 0b01
|
||||||
|
|
||||||
|
|
||||||
class Permission(enum.IntFlag):
|
class Permission(ByValue, enum.IntFlag):
|
||||||
disabled = 0b000 # 0, completely disables access
|
disabled = 0b000 # 0, completely disables access
|
||||||
enabled = 0b001 # 1, allows manual use
|
enabled = 0b001 # 1, allows manual use
|
||||||
goal = 0b010 # 2, allows manual use after goal completion
|
goal = 0b010 # 2, allows manual use after goal completion
|
||||||
auto = 0b110 # 6, forces use after goal completion, only works for forfeit
|
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
|
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -86,7 +86,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
|||||||
data = obj._asdict()
|
data = obj._asdict()
|
||||||
data["class"] = obj.__class__.__name__
|
data["class"] = obj.__class__.__name__
|
||||||
return data
|
return data
|
||||||
if isinstance(obj, (tuple, list, set)):
|
if isinstance(obj, (tuple, list, set, frozenset)):
|
||||||
return tuple(_scan_for_TypedTuples(o) for o in obj)
|
return tuple(_scan_for_TypedTuples(o) for o in obj)
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return {key: _scan_for_TypedTuples(value) for key, value in obj.items()}
|
return {key: _scan_for_TypedTuples(value) for key, value in obj.items()}
|
||||||
@@ -100,7 +100,7 @@ _encode = JSONEncoder(
|
|||||||
).encode
|
).encode
|
||||||
|
|
||||||
|
|
||||||
def encode(obj):
|
def encode(obj: typing.Any) -> str:
|
||||||
return _encode(_scan_for_TypedTuples(obj))
|
return _encode(_scan_for_TypedTuples(obj))
|
||||||
|
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ def get_any_version(data: dict) -> Version:
|
|||||||
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
|
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
|
||||||
|
|
||||||
|
|
||||||
whitelist = {
|
allowlist = {
|
||||||
"NetworkPlayer": NetworkPlayer,
|
"NetworkPlayer": NetworkPlayer,
|
||||||
"NetworkItem": NetworkItem,
|
"NetworkItem": NetworkItem,
|
||||||
"NetworkSlot": NetworkSlot
|
"NetworkSlot": NetworkSlot
|
||||||
@@ -125,7 +125,7 @@ def _object_hook(o: typing.Any) -> typing.Any:
|
|||||||
hook = custom_hooks.get(o.get("class", None), None)
|
hook = custom_hooks.get(o.get("class", None), None)
|
||||||
if hook:
|
if hook:
|
||||||
return hook(o)
|
return hook(o)
|
||||||
cls = whitelist.get(o.get("class", None), None)
|
cls = allowlist.get(o.get("class", None), None)
|
||||||
if cls:
|
if cls:
|
||||||
for key in tuple(o):
|
for key in tuple(o):
|
||||||
if key not in cls._fields:
|
if key not in cls._fields:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import argparse
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import os
|
import os
|
||||||
|
import zipfile
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
|
||||||
from BaseClasses import MultiWorld
|
from BaseClasses import MultiWorld
|
||||||
@@ -43,7 +44,7 @@ def adjustGUI():
|
|||||||
StringVar, IntVar, Checkbutton, Frame, Label, X, Entry, Button, \
|
StringVar, IntVar, Checkbutton, Frame, Label, X, Entry, Button, \
|
||||||
OptionMenu, filedialog, messagebox, ttk
|
OptionMenu, filedialog, messagebox, ttk
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from Main import __version__ as MWVersion
|
from Utils import __version__ as MWVersion
|
||||||
|
|
||||||
window = tk.Tk()
|
window = tk.Tk()
|
||||||
window.wm_title(f"Archipelago {MWVersion} OoT Adjuster")
|
window.wm_title(f"Archipelago {MWVersion} OoT Adjuster")
|
||||||
@@ -196,7 +197,7 @@ def set_icon(window):
|
|||||||
def adjust(args):
|
def adjust(args):
|
||||||
# Create a fake world and OOTWorld to use as a base
|
# Create a fake world and OOTWorld to use as a base
|
||||||
world = MultiWorld(1)
|
world = MultiWorld(1)
|
||||||
world.slot_seeds = {1: random}
|
world.per_slot_randoms = {1: random}
|
||||||
ootworld = OOTWorld(world, 1)
|
ootworld = OOTWorld(world, 1)
|
||||||
# Set options in the fake OOTWorld
|
# Set options in the fake OOTWorld
|
||||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||||
@@ -217,13 +218,18 @@ def adjust(args):
|
|||||||
# Load up the ROM
|
# Load up the ROM
|
||||||
rom = Rom(file=args.rom, force_use=True)
|
rom = Rom(file=args.rom, force_use=True)
|
||||||
delete_zootdec = True
|
delete_zootdec = True
|
||||||
elif os.path.splitext(args.rom)[-1] == '.apz5':
|
elif os.path.splitext(args.rom)[-1] in ['.apz5', '.zpf']:
|
||||||
# Load vanilla ROM
|
# Load vanilla ROM
|
||||||
rom = Rom(file=args.vanilla_rom, force_use=True)
|
rom = Rom(file=args.vanilla_rom, force_use=True)
|
||||||
|
apz5_file = args.rom
|
||||||
|
base_name = os.path.splitext(apz5_file)[0]
|
||||||
# Patch file
|
# Patch file
|
||||||
apply_patch_file(rom, args.rom)
|
apply_patch_file(rom, apz5_file,
|
||||||
|
sub_file=(os.path.basename(base_name) + '.zpf'
|
||||||
|
if zipfile.is_zipfile(apz5_file)
|
||||||
|
else None))
|
||||||
else:
|
else:
|
||||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
|
raise Exception("Invalid file extension; requires .n64, .z64, .apz5, .zpf")
|
||||||
# Call patch_cosmetics
|
# Call patch_cosmetics
|
||||||
try:
|
try:
|
||||||
patch_cosmetics(ootworld, rom)
|
patch_cosmetics(ootworld, rom)
|
||||||
|
|||||||
95
OoTClient.py
95
OoTClient.py
@@ -3,20 +3,23 @@ import json
|
|||||||
import os
|
import os
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import zipfile
|
||||||
from asyncio import StreamReader, StreamWriter
|
from asyncio import StreamReader, StreamWriter
|
||||||
|
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
|
# CommonClient import first to trigger ModuleUpdater
|
||||||
|
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
||||||
ClientCommandProcessor, logger, get_base_parser
|
ClientCommandProcessor, logger, get_base_parser
|
||||||
import Utils
|
import Utils
|
||||||
|
from Utils import async_start
|
||||||
from worlds import network_data_package
|
from worlds import network_data_package
|
||||||
from worlds.oot.Rom import Rom, compress_rom_file
|
from worlds.oot.Rom import Rom, compress_rom_file
|
||||||
from worlds.oot.N64Patch import apply_patch_file
|
from worlds.oot.N64Patch import apply_patch_file
|
||||||
from worlds.oot.Utils import data_path
|
from worlds.oot.Utils import data_path
|
||||||
|
|
||||||
|
|
||||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart oot_connector.lua"
|
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 oot_connector.lua is running"
|
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 oot_connector.lua"
|
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_oot.lua"
|
||||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||||
@@ -48,7 +51,7 @@ deathlink_sent_this_death: we interacted with the multiworld on this death, wait
|
|||||||
|
|
||||||
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
||||||
|
|
||||||
script_version: int = 2
|
script_version: int = 3
|
||||||
|
|
||||||
def get_item_value(ap_id):
|
def get_item_value(ap_id):
|
||||||
return ap_id - 66000
|
return ap_id - 66000
|
||||||
@@ -68,7 +71,7 @@ class OoTCommandProcessor(ClientCommandProcessor):
|
|||||||
if isinstance(self.ctx, OoTContext):
|
if isinstance(self.ctx, OoTContext):
|
||||||
self.ctx.deathlink_client_override = True
|
self.ctx.deathlink_client_override = True
|
||||||
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
||||||
asyncio.create_task(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
async_start(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
||||||
|
|
||||||
|
|
||||||
class OoTContext(CommonContext):
|
class OoTContext(CommonContext):
|
||||||
@@ -83,6 +86,9 @@ class OoTContext(CommonContext):
|
|||||||
self.n64_status = CONNECTION_INITIAL_STATUS
|
self.n64_status = CONNECTION_INITIAL_STATUS
|
||||||
self.awaiting_rom = False
|
self.awaiting_rom = False
|
||||||
self.location_table = {}
|
self.location_table = {}
|
||||||
|
self.collectible_table = {}
|
||||||
|
self.collectible_override_flags_address = 0
|
||||||
|
self.collectible_offsets = {}
|
||||||
self.deathlink_enabled = False
|
self.deathlink_enabled = False
|
||||||
self.deathlink_pending = False
|
self.deathlink_pending = False
|
||||||
self.deathlink_sent_this_death = False
|
self.deathlink_sent_this_death = False
|
||||||
@@ -94,7 +100,7 @@ class OoTContext(CommonContext):
|
|||||||
await super(OoTContext, self).server_auth(password_requested)
|
await super(OoTContext, self).server_auth(password_requested)
|
||||||
if not self.auth:
|
if not self.auth:
|
||||||
self.awaiting_rom = True
|
self.awaiting_rom = True
|
||||||
logger.info('Awaiting connection to Bizhawk to get player information')
|
logger.info('Awaiting connection to EmuHawk to get player information')
|
||||||
return
|
return
|
||||||
|
|
||||||
await self.send_connect()
|
await self.send_connect()
|
||||||
@@ -115,6 +121,13 @@ class OoTContext(CommonContext):
|
|||||||
self.ui = OoTManager(self)
|
self.ui = OoTManager(self)
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
|
||||||
|
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):
|
def get_payload(ctx: OoTContext):
|
||||||
if ctx.deathlink_enabled and ctx.deathlink_pending:
|
if ctx.deathlink_enabled and ctx.deathlink_pending:
|
||||||
@@ -123,15 +136,32 @@ def get_payload(ctx: OoTContext):
|
|||||||
else:
|
else:
|
||||||
trigger_death = False
|
trigger_death = False
|
||||||
|
|
||||||
return json.dumps({
|
payload = json.dumps({
|
||||||
"items": [get_item_value(item.item) for item in ctx.items_received],
|
"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],
|
"playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
|
||||||
"triggerDeath": trigger_death
|
"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):
|
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
|
# 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:
|
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
|
||||||
await ctx.update_death_link(True)
|
await ctx.update_death_link(True)
|
||||||
@@ -146,11 +176,23 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
|||||||
ctx.finished_game = True
|
ctx.finished_game = True
|
||||||
|
|
||||||
# Locations handling
|
# Locations handling
|
||||||
if ctx.location_table != payload['locations']:
|
locations = payload['locations']
|
||||||
ctx.location_table = 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([{
|
await ctx.send_msgs([{
|
||||||
"cmd": "LocationChecks",
|
"cmd": "LocationChecks",
|
||||||
"locations": [oot_loc_name_to_id[loc] for loc in ctx.location_table if ctx.location_table[loc]]
|
"locations": locs1 + locs2
|
||||||
}])
|
}])
|
||||||
|
|
||||||
# Deathlink handling
|
# Deathlink handling
|
||||||
@@ -176,20 +218,13 @@ async def n64_sync_task(ctx: OoTContext):
|
|||||||
try:
|
try:
|
||||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||||
try:
|
try:
|
||||||
# Data will return a dict with up to six fields:
|
|
||||||
# 1. str: player name (always)
|
|
||||||
# 2. int: script version (always)
|
|
||||||
# 3. bool: deathlink active (always)
|
|
||||||
# 4. dict[str, bool]: checked locations
|
|
||||||
# 5. bool: whether Link is currently at 0 HP
|
|
||||||
# 6. bool: whether the game currently registers as complete
|
|
||||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||||
data_decoded = json.loads(data.decode())
|
data_decoded = json.loads(data.decode())
|
||||||
reported_version = data_decoded.get('scriptVersion', 0)
|
reported_version = data_decoded.get('scriptVersion', 0)
|
||||||
if reported_version >= script_version:
|
if reported_version >= script_version:
|
||||||
if ctx.game is not None and 'locations' in data_decoded:
|
if ctx.game is not None and 'locations' in data_decoded:
|
||||||
# Not just a keep alive ping, parse
|
# Not just a keep alive ping, parse
|
||||||
asyncio.create_task(parse_payload(data_decoded, ctx, False))
|
async_start(parse_payload(data_decoded, ctx, False))
|
||||||
if not ctx.auth:
|
if not ctx.auth:
|
||||||
ctx.auth = data_decoded['playerName']
|
ctx.auth = data_decoded['playerName']
|
||||||
if ctx.awaiting_rom:
|
if ctx.awaiting_rom:
|
||||||
@@ -255,17 +290,29 @@ async def run_game(romfile):
|
|||||||
|
|
||||||
|
|
||||||
async def patch_and_run_game(apz5_file):
|
async def patch_and_run_game(apz5_file):
|
||||||
|
apz5_file = os.path.abspath(apz5_file)
|
||||||
base_name = os.path.splitext(apz5_file)[0]
|
base_name = os.path.splitext(apz5_file)[0]
|
||||||
decomp_path = base_name + '-decomp.z64'
|
decomp_path = base_name + '-decomp.z64'
|
||||||
comp_path = base_name + '.z64'
|
comp_path = base_name + '.z64'
|
||||||
# Load vanilla ROM, patch file, compress ROM
|
# Load vanilla ROM, patch file, compress ROM
|
||||||
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
|
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
|
||||||
apply_patch_file(rom, apz5_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)
|
rom.write_to_file(decomp_path)
|
||||||
os.chdir(data_path("Compress"))
|
os.chdir(data_path("Compress"))
|
||||||
compress_rom_file(decomp_path, comp_path)
|
compress_rom_file(decomp_path, comp_path)
|
||||||
os.remove(decomp_path)
|
os.remove(decomp_path)
|
||||||
asyncio.create_task(run_game(comp_path))
|
async_start(run_game(comp_path))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
@@ -281,7 +328,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
if args.apz5_file:
|
if args.apz5_file:
|
||||||
logger.info("APZ5 file supplied, beginning patching process...")
|
logger.info("APZ5 file supplied, beginning patching process...")
|
||||||
asyncio.create_task(patch_and_run_game(args.apz5_file))
|
async_start(patch_and_run_game(args.apz5_file))
|
||||||
|
|
||||||
ctx = OoTContext(args.connect, args.password)
|
ctx = OoTContext(args.connect, args.password)
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||||
|
|||||||
395
Options.py
395
Options.py
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import abc
|
import abc
|
||||||
|
import logging
|
||||||
|
from copy import deepcopy
|
||||||
import math
|
import math
|
||||||
import numbers
|
import numbers
|
||||||
import typing
|
import typing
|
||||||
@@ -8,6 +10,11 @@ import random
|
|||||||
from schema import Schema, And, Or, Optional
|
from schema import Schema, And, Or, Optional
|
||||||
from Utils import get_fuzzy_results
|
from Utils import get_fuzzy_results
|
||||||
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
from BaseClasses import PlandoOptions
|
||||||
|
from worlds.AutoWorld import World
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
|
||||||
class AssembleOptions(abc.ABCMeta):
|
class AssembleOptions(abc.ABCMeta):
|
||||||
def __new__(mcs, name, bases, attrs):
|
def __new__(mcs, name, bases, attrs):
|
||||||
@@ -26,15 +33,31 @@ class AssembleOptions(abc.ABCMeta):
|
|||||||
|
|
||||||
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
||||||
options.update(new_options)
|
options.update(new_options)
|
||||||
|
|
||||||
# apply aliases, without name_lookup
|
# apply aliases, without name_lookup
|
||||||
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||||
name.startswith("alias_")}
|
name.startswith("alias_")}
|
||||||
|
|
||||||
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
|
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
|
||||||
|
|
||||||
|
# auto-alias Off and On being parsed as True and False
|
||||||
|
if "off" in options:
|
||||||
|
options["false"] = options["off"]
|
||||||
|
if "on" in options:
|
||||||
|
options["true"] = options["on"]
|
||||||
|
|
||||||
options.update(aliases)
|
options.update(aliases)
|
||||||
|
|
||||||
|
if "verify" not in attrs:
|
||||||
|
# not overridden by class -> look up bases
|
||||||
|
verifiers = [f for f in (getattr(base, "verify", None) for base in bases) if f]
|
||||||
|
if len(verifiers) > 1: # verify multiple bases/mixins
|
||||||
|
def verify(self, *args, **kwargs) -> None:
|
||||||
|
for f in verifiers:
|
||||||
|
f(self, *args, **kwargs)
|
||||||
|
attrs["verify"] = verify
|
||||||
|
else:
|
||||||
|
assert verifiers, "class Option is supposed to implement def verify"
|
||||||
|
|
||||||
# auto-validate schema on __init__
|
# auto-validate schema on __init__
|
||||||
if "schema" in attrs.keys():
|
if "schema" in attrs.keys():
|
||||||
|
|
||||||
@@ -78,11 +101,11 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
supports_weighting = True
|
supports_weighting = True
|
||||||
|
|
||||||
# filled by AssembleOptions:
|
# filled by AssembleOptions:
|
||||||
name_lookup: typing.Dict[int, str]
|
name_lookup: typing.Dict[T, str]
|
||||||
options: typing.Dict[str, int]
|
options: typing.Dict[str, int]
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"{self.__class__.__name__}({self.get_current_option_name()})"
|
return f"{self.__class__.__name__}({self.current_option_name})"
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
return hash(self.value)
|
return hash(self.value)
|
||||||
@@ -92,7 +115,14 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
return self.name_lookup[self.value]
|
return self.name_lookup[self.value]
|
||||||
|
|
||||||
def get_current_option_name(self) -> str:
|
def get_current_option_name(self) -> str:
|
||||||
"""For display purposes."""
|
"""Deprecated. use current_option_name instead. TODO remove around 0.4"""
|
||||||
|
logging.warning(DeprecationWarning(f"get_current_option_name for {self.__class__.__name__} is deprecated."
|
||||||
|
f" use current_option_name instead. Worlds should use {self}.current_key"))
|
||||||
|
return self.current_option_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_option_name(self) -> str:
|
||||||
|
"""For display purposes. Worlds should be using current_key."""
|
||||||
return self.get_option_name(self.value)
|
return self.get_option_name(self.value)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -109,11 +139,45 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
return bool(self.value)
|
return bool(self.value)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@abc.abstractmethod
|
||||||
def from_any(cls, data: typing.Any) -> Option[T]:
|
def from_any(cls, data: typing.Any) -> Option[T]:
|
||||||
raise NotImplementedError
|
...
|
||||||
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
def verify(self, *args, **kwargs) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class NumericOption(Option[int], numbers.Integral):
|
class FreeText(Option[str]):
|
||||||
|
"""Text option that allows users to enter strings.
|
||||||
|
Needs to be validated by the world or option definition."""
|
||||||
|
|
||||||
|
def __init__(self, value: str):
|
||||||
|
assert isinstance(value, str), "value of FreeText must be a string"
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_key(self) -> str:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_text(cls, text: str) -> FreeText:
|
||||||
|
return cls(text)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_any(cls, data: typing.Any) -> FreeText:
|
||||||
|
return cls.from_text(str(data))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_option_name(cls, value: str) -> str:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class NumericOption(Option[int], numbers.Integral, abc.ABC):
|
||||||
|
default = 0
|
||||||
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
||||||
# `int` is not a `numbers.Integral` according to the official typestubs
|
# `int` is not a `numbers.Integral` according to the official typestubs
|
||||||
# (even though isinstance(5, numbers.Integral) == True)
|
# (even though isinstance(5, numbers.Integral) == True)
|
||||||
@@ -298,7 +362,7 @@ class Toggle(NumericOption):
|
|||||||
if type(data) == str:
|
if type(data) == str:
|
||||||
return cls.from_text(data)
|
return cls.from_text(data)
|
||||||
else:
|
else:
|
||||||
return cls(data)
|
return cls(int(data))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_option_name(cls, value):
|
def get_option_name(cls, value):
|
||||||
@@ -368,6 +432,169 @@ class Choice(NumericOption):
|
|||||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||||
|
|
||||||
|
|
||||||
|
class TextChoice(Choice):
|
||||||
|
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
|
||||||
|
value: typing.Union[str, int]
|
||||||
|
|
||||||
|
def __init__(self, value: typing.Union[str, int]):
|
||||||
|
assert isinstance(value, str) or isinstance(value, int), \
|
||||||
|
f"{value} is not a valid option for {self.__class__.__name__}"
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_key(self) -> str:
|
||||||
|
if isinstance(self.value, str):
|
||||||
|
return self.value
|
||||||
|
return super().current_key
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_text(cls, text: str) -> TextChoice:
|
||||||
|
if text.lower() == "random": # chooses a random defined option but won't use any free text options
|
||||||
|
return cls(random.choice(list(cls.name_lookup)))
|
||||||
|
for option_name, value in cls.options.items():
|
||||||
|
if option_name.lower() == text.lower():
|
||||||
|
return cls(value)
|
||||||
|
return cls(text)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_option_name(cls, value: T) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return super().get_option_name(value)
|
||||||
|
|
||||||
|
def __eq__(self, other: typing.Any):
|
||||||
|
if isinstance(other, self.__class__):
|
||||||
|
return other.value == self.value
|
||||||
|
elif isinstance(other, str):
|
||||||
|
if other in self.options:
|
||||||
|
return other == self.current_key
|
||||||
|
return other == self.value
|
||||||
|
elif isinstance(other, int):
|
||||||
|
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
|
||||||
|
return other == self.value
|
||||||
|
elif isinstance(other, bool):
|
||||||
|
return other == bool(self.value)
|
||||||
|
else:
|
||||||
|
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||||
|
|
||||||
|
|
||||||
|
class BossMeta(AssembleOptions):
|
||||||
|
def __new__(mcs, name, bases, attrs):
|
||||||
|
if name != "PlandoBosses":
|
||||||
|
assert "bosses" in attrs, f"Please define valid bosses for {name}"
|
||||||
|
attrs["bosses"] = frozenset((boss.lower() for boss in attrs["bosses"]))
|
||||||
|
assert "locations" in attrs, f"Please define valid locations for {name}"
|
||||||
|
attrs["locations"] = frozenset((location.lower() for location in attrs["locations"]))
|
||||||
|
cls = super().__new__(mcs, name, bases, attrs)
|
||||||
|
assert not cls.duplicate_bosses or "singularity" in cls.options, f"Please define option_singularity for {name}"
|
||||||
|
return cls
|
||||||
|
|
||||||
|
|
||||||
|
class PlandoBosses(TextChoice, metaclass=BossMeta):
|
||||||
|
"""Generic boss shuffle option that supports plando. Format expected is
|
||||||
|
'location1-boss1;location2-boss2;shuffle_mode'.
|
||||||
|
If shuffle_mode is not provided in the string, this will be the default shuffle mode. Must override can_place_boss,
|
||||||
|
which passes a plando boss and location. Check if the placement is valid for your game here."""
|
||||||
|
bosses: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
|
||||||
|
locations: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
|
||||||
|
|
||||||
|
duplicate_bosses: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_text(cls, text: str):
|
||||||
|
# set all of our text to lower case for name checking
|
||||||
|
text = text.lower()
|
||||||
|
if text == "random":
|
||||||
|
return cls(random.choice(list(cls.options.values())))
|
||||||
|
for option_name, value in cls.options.items():
|
||||||
|
if option_name == text:
|
||||||
|
return cls(value)
|
||||||
|
options = text.split(";")
|
||||||
|
|
||||||
|
# since plando exists in the option verify the plando values given are valid
|
||||||
|
cls.validate_plando_bosses(options)
|
||||||
|
return cls.get_shuffle_mode(options)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_shuffle_mode(cls, option_list: typing.List[str]):
|
||||||
|
# find out what mode of boss shuffle we should use for placing bosses after plando
|
||||||
|
# and add as a string to look nice in the spoiler
|
||||||
|
if "random" in option_list:
|
||||||
|
shuffle = random.choice(list(cls.options))
|
||||||
|
option_list.remove("random")
|
||||||
|
options = ";".join(option_list) + f";{shuffle}"
|
||||||
|
boss_class = cls(options)
|
||||||
|
else:
|
||||||
|
for option in option_list:
|
||||||
|
if option in cls.options:
|
||||||
|
options = ";".join(option_list)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if cls.duplicate_bosses and len(option_list) == 1:
|
||||||
|
if cls.valid_boss_name(option_list[0]):
|
||||||
|
# this doesn't exist in this class but it's a forced option for classes where this is called
|
||||||
|
options = option_list[0] + ";singularity"
|
||||||
|
else:
|
||||||
|
options = option_list[0] + f";{cls.name_lookup[cls.default]}"
|
||||||
|
else:
|
||||||
|
options = ";".join(option_list) + f";{cls.name_lookup[cls.default]}"
|
||||||
|
boss_class = cls(options)
|
||||||
|
return boss_class
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate_plando_bosses(cls, options: typing.List[str]) -> None:
|
||||||
|
used_locations = []
|
||||||
|
used_bosses = []
|
||||||
|
for option in options:
|
||||||
|
# check if a shuffle mode was provided in the incorrect location
|
||||||
|
if option == "random" or option in cls.options:
|
||||||
|
if option != options[-1]:
|
||||||
|
raise ValueError(f"{option} option must be at the end of the boss_shuffle options!")
|
||||||
|
elif "-" in option:
|
||||||
|
location, boss = option.split("-")
|
||||||
|
if location in used_locations:
|
||||||
|
raise ValueError(f"Duplicate Boss Location {location} not allowed.")
|
||||||
|
if not cls.duplicate_bosses and boss in used_bosses:
|
||||||
|
raise ValueError(f"Duplicate Boss {boss} not allowed.")
|
||||||
|
used_locations.append(location)
|
||||||
|
used_bosses.append(boss)
|
||||||
|
if not cls.valid_boss_name(boss):
|
||||||
|
raise ValueError(f"{boss.title()} is not a valid boss name.")
|
||||||
|
if not cls.valid_location_name(location):
|
||||||
|
raise ValueError(f"{location.title()} is not a valid boss location name.")
|
||||||
|
if not cls.can_place_boss(boss, location):
|
||||||
|
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
|
||||||
|
else:
|
||||||
|
if cls.duplicate_bosses:
|
||||||
|
if not cls.valid_boss_name(option):
|
||||||
|
raise ValueError(f"{option} is not a valid boss name.")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"{option.title()} is not formatted correctly.")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def can_place_boss(cls, boss: str, location: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def valid_boss_name(cls, value: str) -> bool:
|
||||||
|
return value in cls.bosses
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def valid_location_name(cls, value: str) -> bool:
|
||||||
|
return value in cls.locations
|
||||||
|
|
||||||
|
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||||
|
if isinstance(self.value, int):
|
||||||
|
return
|
||||||
|
from BaseClasses import PlandoOptions
|
||||||
|
if not(PlandoOptions.bosses & plando_options):
|
||||||
|
# plando is disabled but plando options were given so pull the option and change it to an int
|
||||||
|
option = self.value.split(";")[-1]
|
||||||
|
self.value = self.options[option]
|
||||||
|
logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} "
|
||||||
|
f"boss shuffle will be used for player {player_name}.")
|
||||||
|
|
||||||
|
|
||||||
class Range(NumericOption):
|
class Range(NumericOption):
|
||||||
range_start = 0
|
range_start = 0
|
||||||
range_end = 1
|
range_end = 1
|
||||||
@@ -385,7 +612,7 @@ class Range(NumericOption):
|
|||||||
if text.startswith("random"):
|
if text.startswith("random"):
|
||||||
return cls.weighted_range(text)
|
return cls.weighted_range(text)
|
||||||
elif text == "default" and hasattr(cls, "default"):
|
elif text == "default" and hasattr(cls, "default"):
|
||||||
return cls(cls.default)
|
return cls.from_any(cls.default)
|
||||||
elif text == "high":
|
elif text == "high":
|
||||||
return cls(cls.range_end)
|
return cls(cls.range_end)
|
||||||
elif text == "low":
|
elif text == "low":
|
||||||
@@ -396,7 +623,7 @@ class Range(NumericOption):
|
|||||||
and text in ("true", "false"):
|
and text in ("true", "false"):
|
||||||
# these are the conditions where "true" and "false" make sense
|
# these are the conditions where "true" and "false" make sense
|
||||||
if text == "true":
|
if text == "true":
|
||||||
return cls(cls.default)
|
return cls.from_any(cls.default)
|
||||||
else: # "false"
|
else: # "false"
|
||||||
return cls(0)
|
return cls(0)
|
||||||
return cls(int(text))
|
return cls(int(text))
|
||||||
@@ -489,8 +716,16 @@ class SpecialRange(Range):
|
|||||||
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||||
|
|
||||||
|
|
||||||
class VerifyKeys:
|
class FreezeValidKeys(AssembleOptions):
|
||||||
valid_keys = frozenset()
|
def __new__(mcs, name, bases, attrs):
|
||||||
|
if "valid_keys" in attrs:
|
||||||
|
attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
|
||||||
|
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyKeys(metaclass=FreezeValidKeys):
|
||||||
|
valid_keys: typing.Iterable = []
|
||||||
|
_valid_keys: frozenset # gets created by AssembleOptions from valid_keys
|
||||||
valid_keys_casefold: bool = False
|
valid_keys_casefold: bool = False
|
||||||
convert_name_groups: bool = False
|
convert_name_groups: bool = False
|
||||||
verify_item_name: bool = False
|
verify_item_name: bool = False
|
||||||
@@ -498,21 +733,26 @@ class VerifyKeys:
|
|||||||
value: typing.Any
|
value: typing.Any
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def verify_keys(cls, data):
|
def verify_keys(cls, data: typing.List[str]):
|
||||||
if cls.valid_keys:
|
if cls.valid_keys:
|
||||||
data = set(data)
|
data = set(data)
|
||||||
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
|
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
|
||||||
extra = dataset - cls.valid_keys
|
extra = dataset - cls._valid_keys
|
||||||
if extra:
|
if extra:
|
||||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||||
f"Allowed keys: {cls.valid_keys}.")
|
f"Allowed keys: {cls._valid_keys}.")
|
||||||
|
|
||||||
def verify(self, world):
|
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||||
if self.convert_name_groups and self.verify_item_name:
|
if self.convert_name_groups and self.verify_item_name:
|
||||||
new_value = type(self.value)() # empty container of whatever value is
|
new_value = type(self.value)() # empty container of whatever value is
|
||||||
for item_name in self.value:
|
for item_name in self.value:
|
||||||
new_value |= world.item_name_groups.get(item_name, {item_name})
|
new_value |= world.item_name_groups.get(item_name, {item_name})
|
||||||
self.value = new_value
|
self.value = new_value
|
||||||
|
elif self.convert_name_groups and self.verify_location_name:
|
||||||
|
new_value = type(self.value)()
|
||||||
|
for loc_name in self.value:
|
||||||
|
new_value |= world.location_name_groups.get(loc_name, {loc_name})
|
||||||
|
self.value = new_value
|
||||||
if self.verify_item_name:
|
if self.verify_item_name:
|
||||||
for item_name in self.value:
|
for item_name in self.value:
|
||||||
if item_name not in world.item_names:
|
if item_name not in world.item_names:
|
||||||
@@ -530,11 +770,11 @@ class VerifyKeys:
|
|||||||
|
|
||||||
|
|
||||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
||||||
default = {}
|
default: typing.Dict[str, typing.Any] = {}
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.Dict[str, typing.Any]):
|
def __init__(self, value: typing.Dict[str, typing.Any]):
|
||||||
self.value = value
|
self.value = deepcopy(value)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
||||||
@@ -561,11 +801,15 @@ class ItemDict(OptionDict):
|
|||||||
|
|
||||||
|
|
||||||
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||||
default = []
|
# Supports duplicate entries and ordering.
|
||||||
|
# If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead.
|
||||||
|
# Not a docstring so it doesn't get grabbed by the options system.
|
||||||
|
|
||||||
|
default: typing.List[typing.Any] = []
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.List[typing.Any]):
|
def __init__(self, value: typing.List[typing.Any]):
|
||||||
self.value = value or []
|
self.value = deepcopy(value)
|
||||||
super(OptionList, self).__init__()
|
super(OptionList, self).__init__()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -587,11 +831,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
|||||||
|
|
||||||
|
|
||||||
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||||
default = frozenset()
|
default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
|
|
||||||
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
|
def __init__(self, value: typing.Iterable[str]):
|
||||||
self.value = set(value)
|
self.value = set(deepcopy(value))
|
||||||
super(OptionSet, self).__init__()
|
super(OptionSet, self).__init__()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -600,10 +844,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: typing.Any):
|
def from_any(cls, data: typing.Any):
|
||||||
if type(data) == list:
|
if isinstance(data, (list, set, frozenset)):
|
||||||
cls.verify_keys(data)
|
|
||||||
return cls(data)
|
|
||||||
elif type(data) == set:
|
|
||||||
cls.verify_keys(data)
|
cls.verify_keys(data)
|
||||||
return cls(data)
|
return cls(data)
|
||||||
return cls.from_text(str(data))
|
return cls.from_text(str(data))
|
||||||
@@ -615,7 +856,9 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
|||||||
return item in self.value
|
return item in self.value
|
||||||
|
|
||||||
|
|
||||||
local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
|
class ItemSet(OptionSet):
|
||||||
|
verify_item_name = True
|
||||||
|
convert_name_groups = True
|
||||||
|
|
||||||
|
|
||||||
class Accessibility(Choice):
|
class Accessibility(Choice):
|
||||||
@@ -633,7 +876,7 @@ class Accessibility(Choice):
|
|||||||
|
|
||||||
class ProgressionBalancing(SpecialRange):
|
class ProgressionBalancing(SpecialRange):
|
||||||
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||||
[0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
||||||
default = 50
|
default = 50
|
||||||
range_start = 0
|
range_start = 0
|
||||||
range_end = 99
|
range_end = 99
|
||||||
@@ -651,11 +894,6 @@ common_options = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ItemSet(OptionSet):
|
|
||||||
verify_item_name = True
|
|
||||||
convert_name_groups = True
|
|
||||||
|
|
||||||
|
|
||||||
class LocalItems(ItemSet):
|
class LocalItems(ItemSet):
|
||||||
"""Forces these items to be in their native world."""
|
"""Forces these items to be in their native world."""
|
||||||
display_name = "Local Items"
|
display_name = "Local Items"
|
||||||
@@ -672,27 +910,36 @@ class StartInventory(ItemDict):
|
|||||||
display_name = "Start Inventory"
|
display_name = "Start Inventory"
|
||||||
|
|
||||||
|
|
||||||
|
class StartInventoryPool(StartInventory):
|
||||||
|
"""Start with these items and don't place them in the world.
|
||||||
|
The game decides what the replacement items will be."""
|
||||||
|
verify_item_name = True
|
||||||
|
display_name = "Start Inventory from Pool"
|
||||||
|
|
||||||
|
|
||||||
class StartHints(ItemSet):
|
class StartHints(ItemSet):
|
||||||
"""Start with these item's locations prefilled into the !hint command."""
|
"""Start with these item's locations prefilled into the !hint command."""
|
||||||
display_name = "Start Hints"
|
display_name = "Start Hints"
|
||||||
|
|
||||||
|
|
||||||
class StartLocationHints(OptionSet):
|
class LocationSet(OptionSet):
|
||||||
|
verify_location_name = True
|
||||||
|
convert_name_groups = True
|
||||||
|
|
||||||
|
|
||||||
|
class StartLocationHints(LocationSet):
|
||||||
"""Start with these locations and their item prefilled into the !hint command"""
|
"""Start with these locations and their item prefilled into the !hint command"""
|
||||||
display_name = "Start Location Hints"
|
display_name = "Start Location Hints"
|
||||||
verify_location_name = True
|
|
||||||
|
|
||||||
|
|
||||||
class ExcludeLocations(OptionSet):
|
class ExcludeLocations(LocationSet):
|
||||||
"""Prevent these locations from having an important item"""
|
"""Prevent these locations from having an important item"""
|
||||||
display_name = "Excluded Locations"
|
display_name = "Excluded Locations"
|
||||||
verify_location_name = True
|
|
||||||
|
|
||||||
|
|
||||||
class PriorityLocations(OptionSet):
|
class PriorityLocations(LocationSet):
|
||||||
"""Prevent these locations from having an unimportant item"""
|
"""Prevent these locations from having an unimportant item"""
|
||||||
display_name = "Priority Locations"
|
display_name = "Priority Locations"
|
||||||
verify_location_name = True
|
|
||||||
|
|
||||||
|
|
||||||
class DeathLink(Toggle):
|
class DeathLink(Toggle):
|
||||||
@@ -710,7 +957,8 @@ class ItemLinks(OptionList):
|
|||||||
Optional("exclude"): [And(str, len)],
|
Optional("exclude"): [And(str, len)],
|
||||||
"replacement_item": Or(And(str, len), None),
|
"replacement_item": Or(And(str, len), None),
|
||||||
Optional("local_items"): [And(str, len)],
|
Optional("local_items"): [And(str, len)],
|
||||||
Optional("non_local_items"): [And(str, len)]
|
Optional("non_local_items"): [And(str, len)],
|
||||||
|
Optional("link_replacement"): Or(None, bool),
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -732,8 +980,9 @@ class ItemLinks(OptionList):
|
|||||||
pool |= {item_name}
|
pool |= {item_name}
|
||||||
return pool
|
return pool
|
||||||
|
|
||||||
def verify(self, world):
|
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||||
super(ItemLinks, self).verify(world)
|
link: dict
|
||||||
|
super(ItemLinks, self).verify(world, player_name, plando_options)
|
||||||
existing_links = set()
|
existing_links = set()
|
||||||
for link in self.value:
|
for link in self.value:
|
||||||
if link["name"] in existing_links:
|
if link["name"] in existing_links:
|
||||||
@@ -757,7 +1006,9 @@ class ItemLinks(OptionList):
|
|||||||
|
|
||||||
intersection = local_items.intersection(non_local_items)
|
intersection = local_items.intersection(non_local_items)
|
||||||
if intersection:
|
if intersection:
|
||||||
raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.")
|
raise Exception(f"item_link {link['name']} has {intersection} "
|
||||||
|
f"items in both its local_items and non_local_items pool.")
|
||||||
|
link.setdefault("link_replacement", None)
|
||||||
|
|
||||||
|
|
||||||
per_game_common_options = {
|
per_game_common_options = {
|
||||||
@@ -772,6 +1023,64 @@ per_game_common_options = {
|
|||||||
"item_links": ItemLinks
|
"item_links": ItemLinks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
|
||||||
|
import os
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
|
from worlds import AutoWorldRegister
|
||||||
|
from Utils import local_path, __version__
|
||||||
|
|
||||||
|
full_path: str
|
||||||
|
|
||||||
|
os.makedirs(target_folder, exist_ok=True)
|
||||||
|
|
||||||
|
# clean out old
|
||||||
|
for file in os.listdir(target_folder):
|
||||||
|
full_path = os.path.join(target_folder, file)
|
||||||
|
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
|
||||||
|
os.unlink(full_path)
|
||||||
|
|
||||||
|
def dictify_range(option: typing.Union[Range, SpecialRange]):
|
||||||
|
data = {option.default: 50}
|
||||||
|
for sub_option in ["random", "random-low", "random-high"]:
|
||||||
|
if sub_option != option.default:
|
||||||
|
data[sub_option] = 0
|
||||||
|
|
||||||
|
notes = {}
|
||||||
|
for name, number in getattr(option, "special_range_names", {}).items():
|
||||||
|
notes[name] = f"equivalent to {number}"
|
||||||
|
if number in data:
|
||||||
|
data[name] = data[number]
|
||||||
|
del data[number]
|
||||||
|
else:
|
||||||
|
data[name] = 0
|
||||||
|
|
||||||
|
return data, notes
|
||||||
|
|
||||||
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
|
if not world.hidden or generate_hidden:
|
||||||
|
all_options: typing.Dict[str, AssembleOptions] = {
|
||||||
|
**per_game_common_options,
|
||||||
|
**world.option_definitions
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(local_path("data", "options.yaml")) as f:
|
||||||
|
file_data = f.read()
|
||||||
|
res = Template(file_data).render(
|
||||||
|
options=all_options,
|
||||||
|
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||||
|
dictify_range=dictify_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
del file_data
|
||||||
|
|
||||||
|
with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||||
|
f.write(res)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
from worlds.alttp.Options import Logic
|
from worlds.alttp.Options import Logic
|
||||||
|
|||||||
427
Patch.py
427
Patch.py
@@ -1,265 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
|
||||||
import json
|
|
||||||
import bsdiff4
|
|
||||||
import yaml
|
|
||||||
import os
|
import os
|
||||||
import lzma
|
|
||||||
import threading
|
|
||||||
import concurrent.futures
|
|
||||||
import zipfile
|
|
||||||
import sys
|
import sys
|
||||||
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
|
from typing import Tuple, Optional, TypedDict
|
||||||
|
|
||||||
import ModuleUpdate
|
if __name__ == "__main__":
|
||||||
ModuleUpdate.update()
|
import ModuleUpdate
|
||||||
|
ModuleUpdate.update()
|
||||||
|
|
||||||
import Utils
|
from worlds.Files import AutoPatchRegister, APDeltaPatch
|
||||||
|
|
||||||
current_patch_version = 4
|
|
||||||
|
|
||||||
|
|
||||||
class AutoPatchRegister(type):
|
class RomMeta(TypedDict):
|
||||||
patch_types: Dict[str, APDeltaPatch] = {}
|
server: str
|
||||||
file_endings: Dict[str, APDeltaPatch] = {}
|
|
||||||
|
|
||||||
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
|
|
||||||
# construct class
|
|
||||||
new_class = super().__new__(cls, name, bases, dct)
|
|
||||||
if "game" in dct:
|
|
||||||
AutoPatchRegister.patch_types[dct["game"]] = new_class
|
|
||||||
if not dct["patch_file_ending"]:
|
|
||||||
raise Exception(f"Need an expected file ending for {name}")
|
|
||||||
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
|
|
||||||
return new_class
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
|
|
||||||
for file_ending, handler in AutoPatchRegister.file_endings.items():
|
|
||||||
if file.endswith(file_ending):
|
|
||||||
return handler
|
|
||||||
|
|
||||||
|
|
||||||
class APContainer:
|
|
||||||
"""A zipfile containing at least archipelago.json"""
|
|
||||||
version: int = current_patch_version
|
|
||||||
compression_level: int = 9
|
|
||||||
compression_method: int = zipfile.ZIP_DEFLATED
|
|
||||||
game: Optional[str] = None
|
|
||||||
|
|
||||||
# instance attributes:
|
|
||||||
path: Optional[str]
|
|
||||||
player: Optional[int]
|
player: Optional[int]
|
||||||
player_name: str
|
player_name: str
|
||||||
server: str
|
|
||||||
|
|
||||||
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
|
|
||||||
player_name: str = "", server: str = ""):
|
|
||||||
self.path = path
|
|
||||||
self.player = player
|
|
||||||
self.player_name = player_name
|
|
||||||
self.server = server
|
|
||||||
|
|
||||||
def write(self, file: Optional[Union[str, BinaryIO]] = None):
|
|
||||||
if not self.path and not file:
|
|
||||||
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
|
|
||||||
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
|
|
||||||
as zf:
|
|
||||||
if file:
|
|
||||||
self.path = zf.filename
|
|
||||||
self.write_contents(zf)
|
|
||||||
|
|
||||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
|
||||||
manifest = self.get_manifest()
|
|
||||||
try:
|
|
||||||
manifest = json.dumps(manifest)
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Manifest {manifest} did not convert to json.") from e
|
|
||||||
else:
|
|
||||||
opened_zipfile.writestr("archipelago.json", manifest)
|
|
||||||
|
|
||||||
def read(self, file: Optional[Union[str, BinaryIO]] = None):
|
|
||||||
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
|
|
||||||
if not self.path and not file:
|
|
||||||
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
|
|
||||||
with zipfile.ZipFile(file if file else self.path, "r") as zf:
|
|
||||||
if file:
|
|
||||||
self.path = zf.filename
|
|
||||||
self.read_contents(zf)
|
|
||||||
|
|
||||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
|
||||||
with opened_zipfile.open("archipelago.json", "r") as f:
|
|
||||||
manifest = json.load(f)
|
|
||||||
if manifest["compatible_version"] > self.version:
|
|
||||||
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
|
|
||||||
f"for this handler (version: {self.version})")
|
|
||||||
self.player = manifest["player"]
|
|
||||||
self.server = manifest["server"]
|
|
||||||
self.player_name = manifest["player_name"]
|
|
||||||
|
|
||||||
def get_manifest(self) -> dict:
|
|
||||||
return {
|
|
||||||
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
|
|
||||||
"player": self.player,
|
|
||||||
"player_name": self.player_name,
|
|
||||||
"game": self.game,
|
|
||||||
# minimum version of patch system expected for patching to be successful
|
|
||||||
"compatible_version": 4,
|
|
||||||
"version": current_patch_version,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
|
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
|
||||||
"""An APContainer that additionally has delta.bsdiff4
|
|
||||||
containing a delta patch to get the desired file, often a rom."""
|
|
||||||
|
|
||||||
hash = Optional[str] # base checksum of source file
|
|
||||||
patch_file_ending: str = ""
|
|
||||||
delta: Optional[bytes] = None
|
|
||||||
result_file_ending: str = ".sfc"
|
|
||||||
source_data: bytes
|
|
||||||
|
|
||||||
def __init__(self, *args, patched_path: str = "", **kwargs):
|
|
||||||
self.patched_path = patched_path
|
|
||||||
super(APDeltaPatch, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_manifest(self) -> dict:
|
|
||||||
manifest = super(APDeltaPatch, self).get_manifest()
|
|
||||||
manifest["base_checksum"] = self.hash
|
|
||||||
manifest["result_file_ending"] = self.result_file_ending
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_source_data(cls) -> bytes:
|
|
||||||
"""Get Base data"""
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_source_data_with_cache(cls) -> bytes:
|
|
||||||
if not hasattr(cls, "source_data"):
|
|
||||||
cls.source_data = cls.get_source_data()
|
|
||||||
return cls.source_data
|
|
||||||
|
|
||||||
def write_contents(self, opened_zipfile: zipfile.ZipFile):
|
|
||||||
super(APDeltaPatch, self).write_contents(opened_zipfile)
|
|
||||||
# write Delta
|
|
||||||
opened_zipfile.writestr("delta.bsdiff4",
|
|
||||||
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
|
|
||||||
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
|
|
||||||
|
|
||||||
def read_contents(self, opened_zipfile: zipfile.ZipFile):
|
|
||||||
super(APDeltaPatch, self).read_contents(opened_zipfile)
|
|
||||||
self.delta = opened_zipfile.read("delta.bsdiff4")
|
|
||||||
|
|
||||||
def patch(self, target: str):
|
|
||||||
"""Base + Delta -> Patched"""
|
|
||||||
if not self.delta:
|
|
||||||
self.read()
|
|
||||||
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
|
|
||||||
with open(target, "wb") as f:
|
|
||||||
f.write(result)
|
|
||||||
|
|
||||||
|
|
||||||
# legacy patch handling follows:
|
|
||||||
GAME_ALTTP = "A Link to the Past"
|
|
||||||
GAME_SM = "Super Metroid"
|
|
||||||
GAME_SOE = "Secret of Evermore"
|
|
||||||
GAME_SMZ3 = "SMZ3"
|
|
||||||
GAME_DKC3 = "Donkey Kong Country 3"
|
|
||||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
|
|
||||||
|
|
||||||
preferred_endings = {
|
|
||||||
GAME_ALTTP: "apbp",
|
|
||||||
GAME_SM: "apm3",
|
|
||||||
GAME_SOE: "apsoe",
|
|
||||||
GAME_SMZ3: "apsmz",
|
|
||||||
GAME_DKC3: "apdkc3"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
|
||||||
if game == GAME_ALTTP:
|
|
||||||
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
|
|
||||||
elif game == GAME_SM:
|
|
||||||
from worlds.sm.Rom import SMJUHASH as HASH
|
|
||||||
elif game == GAME_SOE:
|
|
||||||
from worlds.soe.Patch import USHASH as HASH
|
|
||||||
elif game == GAME_SMZ3:
|
|
||||||
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
|
|
||||||
from worlds.sm.Rom import SMJUHASH as SMHASH
|
|
||||||
HASH = ALTTPHASH + SMHASH
|
|
||||||
elif game == GAME_DKC3:
|
|
||||||
from worlds.dkc3.Rom import USHASH as HASH
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
|
||||||
|
|
||||||
patch = yaml.dump({"meta": metadata,
|
|
||||||
"patch": patch,
|
|
||||||
"game": game,
|
|
||||||
# minimum version of patch system expected for patching to be successful
|
|
||||||
"compatible_version": 3,
|
|
||||||
"version": current_patch_version,
|
|
||||||
"base_checksum": HASH})
|
|
||||||
return patch.encode(encoding="utf-8-sig")
|
|
||||||
|
|
||||||
|
|
||||||
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
|
||||||
if metadata is None:
|
|
||||||
metadata = {}
|
|
||||||
patch = bsdiff4.diff(get_base_rom_data(game), rom)
|
|
||||||
return generate_yaml(patch, metadata, game)
|
|
||||||
|
|
||||||
|
|
||||||
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
|
|
||||||
player: int = 0, player_name: str = "", game: str = GAME_ALTTP) -> str:
|
|
||||||
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
|
|
||||||
"player_id": player,
|
|
||||||
"player_name": player_name}
|
|
||||||
bytes = generate_patch(load_bytes(rom_file_to_patch),
|
|
||||||
meta,
|
|
||||||
game)
|
|
||||||
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
|
|
||||||
".apbp" if game == GAME_ALTTP
|
|
||||||
else ".apsmz" if game == GAME_SMZ3
|
|
||||||
else ".apdkc3" if game == GAME_DKC3
|
|
||||||
else ".apm3")
|
|
||||||
write_lzma(bytes, target)
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
|
|
||||||
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
|
|
||||||
game_name = data["game"]
|
|
||||||
if not ignore_version and data["compatible_version"] > current_patch_version:
|
|
||||||
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
|
|
||||||
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
|
|
||||||
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
|
|
||||||
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
|
|
||||||
target = os.path.splitext(patch_file)[0] + ".sfc"
|
|
||||||
return data["meta"], target, patched_data
|
|
||||||
|
|
||||||
|
|
||||||
def get_base_rom_data(game: str):
|
|
||||||
if game == GAME_ALTTP:
|
|
||||||
from worlds.alttp.Rom import get_base_rom_bytes
|
|
||||||
elif game == "alttp": # old version for A Link to the Past
|
|
||||||
from worlds.alttp.Rom import get_base_rom_bytes
|
|
||||||
elif game == GAME_SM:
|
|
||||||
from worlds.sm.Rom import get_base_rom_bytes
|
|
||||||
elif game == GAME_SOE:
|
|
||||||
from worlds.soe.Patch import get_base_rom_path
|
|
||||||
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
|
|
||||||
elif game == GAME_SMZ3:
|
|
||||||
from worlds.smz3.Rom import get_base_rom_bytes
|
|
||||||
elif game == GAME_DKC3:
|
|
||||||
from worlds.dkc3.Rom import get_base_rom_bytes
|
|
||||||
else:
|
|
||||||
raise RuntimeError("Selected game for base rom not found.")
|
|
||||||
return get_base_rom_bytes()
|
|
||||||
|
|
||||||
|
|
||||||
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
|
||||||
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
||||||
if auto_handler:
|
if auto_handler:
|
||||||
handler: APDeltaPatch = auto_handler(patch_file)
|
handler: APDeltaPatch = auto_handler(patch_file)
|
||||||
@@ -268,171 +26,10 @@ def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
|||||||
return {"server": handler.server,
|
return {"server": handler.server,
|
||||||
"player": handler.player,
|
"player": handler.player,
|
||||||
"player_name": handler.player_name}, target
|
"player_name": handler.player_name}, target
|
||||||
else:
|
raise NotImplementedError(f"No Handler for {patch_file} found.")
|
||||||
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"], data["game"])
|
|
||||||
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 read_rom(stream, strip_header=True) -> bytearray:
|
|
||||||
"""Reads rom into bytearray and optionally strips off any smc header"""
|
|
||||||
buffer = bytearray(stream.read())
|
|
||||||
if strip_header and len(buffer) % 0x400 == 0x200:
|
|
||||||
return buffer[0x200:]
|
|
||||||
return buffer
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
host = Utils.get_public_ipv4()
|
for file in sys.argv[1:]:
|
||||||
options = Utils.get_options()['server_options']
|
meta_data, result_file = create_rom_file(file)
|
||||||
if options['host']:
|
print(f"Patch with meta-data {meta_data} was written to {result_file}")
|
||||||
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)
|
|
||||||
adjuster_settings = Utils.get_adjuster_settings(GAME_ALTTP)
|
|
||||||
adjusted = False
|
|
||||||
if adjuster_settings:
|
|
||||||
import pprint
|
|
||||||
from worlds.alttp.Rom import get_base_rom_path
|
|
||||||
adjuster_settings.rom = target
|
|
||||||
adjuster_settings.baserom = get_base_rom_path()
|
|
||||||
adjuster_settings.world = None
|
|
||||||
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
|
||||||
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
|
|
||||||
"reduceflashing", "deathlink"}
|
|
||||||
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
|
|
||||||
if hasattr(adjuster_settings, "sprite_pool"):
|
|
||||||
sprite_pool = {}
|
|
||||||
for sprite in getattr(adjuster_settings, "sprite_pool"):
|
|
||||||
if sprite in sprite_pool:
|
|
||||||
sprite_pool[sprite] += 1
|
|
||||||
else:
|
|
||||||
sprite_pool[sprite] = 1
|
|
||||||
if sprite_pool:
|
|
||||||
printed_options["sprite_pool"] = sprite_pool
|
|
||||||
|
|
||||||
adjust_wanted = str('no')
|
|
||||||
if not hasattr(adjuster_settings, 'auto_apply') or 'ask' in adjuster_settings.auto_apply:
|
|
||||||
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
|
|
||||||
f"{pprint.pformat(printed_options)}\n"
|
|
||||||
f"Enter yes, no, always or never: ")
|
|
||||||
if adjuster_settings.auto_apply == 'never': # never adjust, per user request
|
|
||||||
adjust_wanted = 'no'
|
|
||||||
elif adjuster_settings.auto_apply == 'always':
|
|
||||||
adjust_wanted = 'yes'
|
|
||||||
|
|
||||||
if adjust_wanted and "never" in adjust_wanted:
|
|
||||||
adjuster_settings.auto_apply = 'never'
|
|
||||||
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
|
||||||
|
|
||||||
elif adjust_wanted and "always" in adjust_wanted:
|
|
||||||
adjuster_settings.auto_apply = 'always'
|
|
||||||
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
|
|
||||||
|
|
||||||
if adjust_wanted and adjust_wanted.startswith("y"):
|
|
||||||
if hasattr(adjuster_settings, "sprite_pool"):
|
|
||||||
from LttPAdjuster import AdjusterWorld
|
|
||||||
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
|
|
||||||
|
|
||||||
adjusted = True
|
|
||||||
import LttPAdjuster
|
|
||||||
_, romfile = LttPAdjuster.adjust(adjuster_settings)
|
|
||||||
|
|
||||||
if hasattr(adjuster_settings, "world"):
|
|
||||||
delattr(adjuster_settings, "world")
|
|
||||||
else:
|
|
||||||
adjusted = False
|
|
||||||
if adjusted:
|
|
||||||
try:
|
|
||||||
shutil.move(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(".apm3"):
|
|
||||||
print(f"Applying patch {rom}")
|
|
||||||
data, target = create_rom_file(rom)
|
|
||||||
print(f"Created rom {target}.")
|
|
||||||
if 'server' in data:
|
|
||||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
|
||||||
print(f"Host is {data['server']}")
|
|
||||||
elif rom.endswith(".apsmz"):
|
|
||||||
print(f"Applying patch {rom}")
|
|
||||||
data, target = create_rom_file(rom)
|
|
||||||
print(f"Created rom {target}.")
|
|
||||||
if 'server' in data:
|
|
||||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
|
||||||
print(f"Host is {data['server']}")
|
|
||||||
elif rom.endswith(".apdkc3"):
|
|
||||||
print(f"Applying patch {rom}")
|
|
||||||
data, target = create_rom_file(rom)
|
|
||||||
print(f"Created rom {target}.")
|
|
||||||
if 'server' in data:
|
|
||||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
|
||||||
print(f"Host is {data['server']}")
|
|
||||||
|
|
||||||
elif rom.endswith(".zip"):
|
|
||||||
print(f"Updating host in patch files contained in {rom}")
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
|
||||||
data = zfr.read(zfinfo)
|
|
||||||
if zfinfo.filename.endswith(".apbp") or \
|
|
||||||
zfinfo.filename.endswith(".apm3") or \
|
|
||||||
zfinfo.filename.endswith(".apdkc3"):
|
|
||||||
data = update_patch_data(data, server)
|
|
||||||
with ziplock:
|
|
||||||
zfw.writestr(zfinfo, data)
|
|
||||||
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.")
|
|
||||||
|
|||||||
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()
|
||||||
43
README.md
43
README.md
@@ -28,6 +28,27 @@ Currently, the following games are supported:
|
|||||||
* Starcraft 2: Wings of Liberty
|
* Starcraft 2: Wings of Liberty
|
||||||
* Donkey Kong Country 3
|
* Donkey Kong Country 3
|
||||||
* Dark Souls 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
|
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||||
@@ -61,26 +82,10 @@ This project makes use of multiple other projects. We wouldn't be here without t
|
|||||||
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
|
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
|
||||||
|
|
||||||
## Contributing
|
## 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.)
|
|
||||||
|
|
||||||
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see [the docs folder](docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
For frequently asked questions see the website's [FAQ Page](https://archipelago.gg/faq/en/)
|
For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/)
|
||||||
|
|
||||||
## Code of Conduct
|
## 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:
|
Please refer to our [code of conduct.](/docs/code_of_conduct.md)
|
||||||
|
|
||||||
* 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.
|
|
||||||
|
|||||||
1200
SNIClient.py
1200
SNIClient.py
File diff suppressed because it is too large
Load Diff
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()
|
||||||
274
Utils.py
274
Utils.py
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
import typing
|
import typing
|
||||||
import builtins
|
import builtins
|
||||||
import os
|
import os
|
||||||
@@ -11,6 +13,8 @@ import io
|
|||||||
import collections
|
import collections
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
|
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
|
||||||
|
|
||||||
from yaml import load, load_all, dump, SafeLoader
|
from yaml import load, load_all, dump, SafeLoader
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -34,8 +38,11 @@ class Version(typing.NamedTuple):
|
|||||||
minor: int
|
minor: int
|
||||||
build: int
|
build: int
|
||||||
|
|
||||||
|
def as_simple_string(self) -> str:
|
||||||
|
return ".".join(str(item) for item in self)
|
||||||
|
|
||||||
__version__ = "0.3.4"
|
|
||||||
|
__version__ = "0.4.2"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
@@ -84,7 +91,10 @@ def is_frozen() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def local_path(*path: str) -> str:
|
def local_path(*path: str) -> str:
|
||||||
"""Returns path to a file in the local Archipelago installation or source."""
|
"""
|
||||||
|
Returns path to a file in the local Archipelago installation or source.
|
||||||
|
This might be read-only and user_path should be used instead for ROMs, configuration, etc.
|
||||||
|
"""
|
||||||
if hasattr(local_path, 'cached_path'):
|
if hasattr(local_path, 'cached_path'):
|
||||||
pass
|
pass
|
||||||
elif is_frozen():
|
elif is_frozen():
|
||||||
@@ -96,7 +106,7 @@ def local_path(*path: str) -> str:
|
|||||||
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
||||||
else:
|
else:
|
||||||
import __main__
|
import __main__
|
||||||
if hasattr(__main__, "__file__"):
|
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
||||||
# we are running in a normal Python environment
|
# we are running in a normal Python environment
|
||||||
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
||||||
else:
|
else:
|
||||||
@@ -139,7 +149,18 @@ def user_path(*path: str) -> str:
|
|||||||
return os.path.join(user_path.cached_path, *path)
|
return os.path.join(user_path.cached_path, *path)
|
||||||
|
|
||||||
|
|
||||||
def output_path(*path: str):
|
def cache_path(*path: str) -> str:
|
||||||
|
"""Returns path to a file in the user's Archipelago cache directory."""
|
||||||
|
if hasattr(cache_path, "cached_path"):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
import platformdirs
|
||||||
|
cache_path.cached_path = platformdirs.user_cache_dir("Archipelago", False)
|
||||||
|
|
||||||
|
return os.path.join(cache_path.cached_path, *path)
|
||||||
|
|
||||||
|
|
||||||
|
def output_path(*path: str) -> str:
|
||||||
if hasattr(output_path, 'cached_path'):
|
if hasattr(output_path, 'cached_path'):
|
||||||
return os.path.join(output_path.cached_path, *path)
|
return os.path.join(output_path.cached_path, *path)
|
||||||
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
|
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
|
||||||
@@ -192,11 +213,11 @@ def get_public_ipv4() -> str:
|
|||||||
ip = socket.gethostbyname(socket.gethostname())
|
ip = socket.gethostbyname(socket.gethostname())
|
||||||
ctx = get_cert_none_ssl_context()
|
ctx = get_cert_none_ssl_context()
|
||||||
try:
|
try:
|
||||||
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx).read().decode("utf8").strip()
|
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# noinspection PyBroadException
|
# noinspection PyBroadException
|
||||||
try:
|
try:
|
||||||
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip()
|
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
pass # we could be offline, in a local game, so no point in erroring out
|
pass # we could be offline, in a local game, so no point in erroring out
|
||||||
@@ -210,15 +231,18 @@ def get_public_ipv6() -> str:
|
|||||||
ip = socket.gethostbyname(socket.gethostname())
|
ip = socket.gethostbyname(socket.gethostname())
|
||||||
ctx = get_cert_none_ssl_context()
|
ctx = get_cert_none_ssl_context()
|
||||||
try:
|
try:
|
||||||
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx).read().decode("utf8").strip()
|
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
pass # we could be offline, in a local game, or ipv6 may not be available
|
pass # we could be offline, in a local game, or ipv6 may not be available
|
||||||
return ip
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
|
||||||
|
|
||||||
|
|
||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_default_options() -> dict:
|
def get_default_options() -> OptionsType:
|
||||||
# Refer to host.yaml for comments as to what all these options mean.
|
# Refer to host.yaml for comments as to what all these options mean.
|
||||||
options = {
|
options = {
|
||||||
"general_options": {
|
"general_options": {
|
||||||
@@ -226,20 +250,24 @@ def get_default_options() -> dict:
|
|||||||
},
|
},
|
||||||
"factorio_options": {
|
"factorio_options": {
|
||||||
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
|
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
|
||||||
|
"filter_item_sends": False,
|
||||||
|
"bridge_chat_out": True,
|
||||||
|
},
|
||||||
|
"sni_options": {
|
||||||
|
"sni_path": "SNI",
|
||||||
|
"snes_rom_start": True,
|
||||||
},
|
},
|
||||||
"sm_options": {
|
"sm_options": {
|
||||||
"rom_file": "Super Metroid (JU).sfc",
|
"rom_file": "Super Metroid (JU).sfc",
|
||||||
"sni": "SNI",
|
|
||||||
"rom_start": True,
|
|
||||||
},
|
},
|
||||||
"soe_options": {
|
"soe_options": {
|
||||||
"rom_file": "Secret of Evermore (USA).sfc",
|
"rom_file": "Secret of Evermore (USA).sfc",
|
||||||
},
|
},
|
||||||
"lttp_options": {
|
"lttp_options": {
|
||||||
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
||||||
"sni": "SNI",
|
},
|
||||||
"rom_start": True,
|
"ladx_options": {
|
||||||
|
"rom_file": "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc",
|
||||||
},
|
},
|
||||||
"server_options": {
|
"server_options": {
|
||||||
"host": None,
|
"host": None,
|
||||||
@@ -253,7 +281,7 @@ def get_default_options() -> dict:
|
|||||||
"disable_item_cheat": False,
|
"disable_item_cheat": False,
|
||||||
"location_check_points": 1,
|
"location_check_points": 1,
|
||||||
"hint_cost": 10,
|
"hint_cost": 10,
|
||||||
"forfeit_mode": "goal",
|
"release_mode": "goal",
|
||||||
"collect_mode": "disabled",
|
"collect_mode": "disabled",
|
||||||
"remaining_mode": "goal",
|
"remaining_mode": "goal",
|
||||||
"auto_shutdown": 0,
|
"auto_shutdown": 0,
|
||||||
@@ -261,13 +289,12 @@ def get_default_options() -> dict:
|
|||||||
"log_network": 0
|
"log_network": 0
|
||||||
},
|
},
|
||||||
"generator": {
|
"generator": {
|
||||||
"teams": 1,
|
|
||||||
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
|
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
|
||||||
"player_files_path": "Players",
|
"player_files_path": "Players",
|
||||||
"players": 0,
|
"players": 0,
|
||||||
"weights_file_path": "weights.yaml",
|
"weights_file_path": "weights.yaml",
|
||||||
"meta_file_path": "meta.yaml",
|
"meta_file_path": "meta.yaml",
|
||||||
"spoiler": 2,
|
"spoiler": 3,
|
||||||
"glitch_triforce_room": 1,
|
"glitch_triforce_room": 1,
|
||||||
"race": 0,
|
"race": 0,
|
||||||
"plando_options": "bosses",
|
"plando_options": "bosses",
|
||||||
@@ -279,18 +306,54 @@ def get_default_options() -> dict:
|
|||||||
},
|
},
|
||||||
"oot_options": {
|
"oot_options": {
|
||||||
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
|
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
|
||||||
|
"rom_start": True
|
||||||
},
|
},
|
||||||
"dkc3_options": {
|
"dkc3_options": {
|
||||||
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
|
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
|
||||||
"sni": "SNI",
|
},
|
||||||
|
"smw_options": {
|
||||||
|
"rom_file": "Super Mario World (USA).sfc",
|
||||||
|
},
|
||||||
|
"zillion_options": {
|
||||||
|
"rom_file": "Zillion (UE) [!].sms",
|
||||||
|
# RetroArch doesn't make it easy to launch a game from the command line.
|
||||||
|
# You have to know the path to the emulator core library on the user's computer.
|
||||||
|
"rom_start": "retroarch",
|
||||||
|
},
|
||||||
|
"pokemon_rb_options": {
|
||||||
|
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
|
||||||
|
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",
|
||||||
|
"rom_start": True
|
||||||
|
},
|
||||||
|
"ffr_options": {
|
||||||
|
"display_msgs": True,
|
||||||
|
},
|
||||||
|
"lufia2ac_options": {
|
||||||
|
"rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc",
|
||||||
|
},
|
||||||
|
"tloz_options": {
|
||||||
|
"rom_file": "Legend of Zelda, The (U) (PRG0) [!].nes",
|
||||||
"rom_start": True,
|
"rom_start": True,
|
||||||
|
"display_msgs": True,
|
||||||
|
},
|
||||||
|
"wargroove_options": {
|
||||||
|
"root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove"
|
||||||
|
},
|
||||||
|
"mmbn3_options": {
|
||||||
|
"rom_file": "Mega Man Battle Network 3 - Blue Version (USA).gba",
|
||||||
|
"rom_start": True
|
||||||
|
},
|
||||||
|
"adventure_options": {
|
||||||
|
"rom_file": "ADVNTURE.BIN",
|
||||||
|
"display_msgs": True,
|
||||||
|
"rom_start": True,
|
||||||
|
"rom_args": ""
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType:
|
||||||
for key, value in src.items():
|
for key, value in src.items():
|
||||||
new_keys = keys.copy()
|
new_keys = keys.copy()
|
||||||
new_keys.append(key)
|
new_keys.append(key)
|
||||||
@@ -310,9 +373,9 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_options() -> dict:
|
def get_options() -> OptionsType:
|
||||||
filenames = ("options.yaml", "host.yaml")
|
filenames = ("options.yaml", "host.yaml")
|
||||||
locations = []
|
locations: typing.List[str] = []
|
||||||
if os.path.join(os.getcwd()) != local_path():
|
if os.path.join(os.getcwd()) != local_path():
|
||||||
locations += filenames # use files from cwd only if it's not the local_path
|
locations += filenames # use files from cwd only if it's not the local_path
|
||||||
locations += [user_path(filename) for filename in filenames]
|
locations += [user_path(filename) for filename in filenames]
|
||||||
@@ -353,7 +416,46 @@ def persistent_load() -> typing.Dict[str, dict]:
|
|||||||
return storage
|
return storage
|
||||||
|
|
||||||
|
|
||||||
def get_adjuster_settings(game_name: str):
|
def get_file_safe_name(name: str) -> str:
|
||||||
|
return "".join(c for c in name if c not in '<>:"/\\|?*')
|
||||||
|
|
||||||
|
|
||||||
|
def load_data_package_for_checksum(game: str, checksum: typing.Optional[str]) -> Dict[str, Any]:
|
||||||
|
if checksum and game:
|
||||||
|
if checksum != get_file_safe_name(checksum):
|
||||||
|
raise ValueError(f"Bad symbols in checksum: {checksum}")
|
||||||
|
path = cache_path("datapackage", get_file_safe_name(game), f"{checksum}.json")
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8-sig") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logging.debug(f"Could not load data package: {e}")
|
||||||
|
|
||||||
|
# fall back to old cache
|
||||||
|
cache = persistent_load().get("datapackage", {}).get("games", {}).get(game, {})
|
||||||
|
if cache.get("checksum") == checksum:
|
||||||
|
return cache
|
||||||
|
|
||||||
|
# cache does not match
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> None:
|
||||||
|
checksum = data.get("checksum")
|
||||||
|
if checksum and game:
|
||||||
|
if checksum != get_file_safe_name(checksum):
|
||||||
|
raise ValueError(f"Bad symbols in checksum: {checksum}")
|
||||||
|
game_folder = cache_path("datapackage", get_file_safe_name(game))
|
||||||
|
os.makedirs(game_folder, exist_ok=True)
|
||||||
|
try:
|
||||||
|
with open(os.path.join(game_folder, f"{checksum}.json"), "w", encoding="utf-8-sig") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
except Exception as e:
|
||||||
|
logging.debug(f"Could not store data package: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]:
|
||||||
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
|
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
|
||||||
return adjuster_settings
|
return adjuster_settings
|
||||||
|
|
||||||
@@ -392,7 +494,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
# Options and Plando are unpickled by WebHost -> Generate
|
# Options and Plando are unpickled by WebHost -> Generate
|
||||||
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
||||||
return getattr(self.generic_properties_module, name)
|
return getattr(self.generic_properties_module, name)
|
||||||
if module.endswith("Options"):
|
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
|
||||||
|
if module.lower().endswith("options"):
|
||||||
if module == "Options":
|
if module == "Options":
|
||||||
mod = self.options_module
|
mod = self.options_module
|
||||||
else:
|
else:
|
||||||
@@ -409,6 +512,15 @@ def restricted_loads(s):
|
|||||||
return RestrictedUnpickler(io.BytesIO(s)).load()
|
return RestrictedUnpickler(io.BytesIO(s)).load()
|
||||||
|
|
||||||
|
|
||||||
|
class ByValue:
|
||||||
|
"""
|
||||||
|
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
|
||||||
|
See https://github.com/python/cpython/pull/26658 for why this exists.
|
||||||
|
"""
|
||||||
|
def __reduce_ex__(self, prot):
|
||||||
|
return self.__class__, (self._value_, )
|
||||||
|
|
||||||
|
|
||||||
class KeyedDefaultDict(collections.defaultdict):
|
class KeyedDefaultDict(collections.defaultdict):
|
||||||
"""defaultdict variant that uses the missing key as argument to default_factory"""
|
"""defaultdict variant that uses the missing key as argument to default_factory"""
|
||||||
default_factory: typing.Callable[[typing.Any], typing.Any]
|
default_factory: typing.Callable[[typing.Any], typing.Any]
|
||||||
@@ -432,6 +544,7 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
|
|||||||
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
|
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
|
||||||
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
||||||
exception_logger: typing.Optional[str] = None):
|
exception_logger: typing.Optional[str] = None):
|
||||||
|
import datetime
|
||||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||||
log_folder = user_path("logs")
|
log_folder = user_path("logs")
|
||||||
os.makedirs(log_folder, exist_ok=True)
|
os.makedirs(log_folder, exist_ok=True)
|
||||||
@@ -440,6 +553,9 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
|||||||
root_logger.removeHandler(handler)
|
root_logger.removeHandler(handler)
|
||||||
handler.close()
|
handler.close()
|
||||||
root_logger.setLevel(loglevel)
|
root_logger.setLevel(loglevel)
|
||||||
|
logging.getLogger("websockets").setLevel(loglevel) # make sure level is applied for websockets
|
||||||
|
if "a" not in write_mode:
|
||||||
|
name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}"
|
||||||
file_handler = logging.FileHandler(
|
file_handler = logging.FileHandler(
|
||||||
os.path.join(log_folder, f"{name}.txt"),
|
os.path.join(log_folder, f"{name}.txt"),
|
||||||
write_mode,
|
write_mode,
|
||||||
@@ -467,7 +583,25 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
|||||||
|
|
||||||
sys.excepthook = handle_exception
|
sys.excepthook = handle_exception
|
||||||
|
|
||||||
logging.info(f"Archipelago ({__version__}) logging initialized.")
|
def _cleanup():
|
||||||
|
for file in os.scandir(log_folder):
|
||||||
|
if file.name.endswith(".txt"):
|
||||||
|
last_change = datetime.datetime.fromtimestamp(file.stat().st_mtime)
|
||||||
|
if datetime.datetime.now() - last_change > datetime.timedelta(days=7):
|
||||||
|
try:
|
||||||
|
os.unlink(file.path)
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(e)
|
||||||
|
else:
|
||||||
|
logging.debug(f"Deleted old logfile {file.path}")
|
||||||
|
import threading
|
||||||
|
threading.Thread(target=_cleanup, name="LogCleaner").start()
|
||||||
|
import platform
|
||||||
|
logging.info(
|
||||||
|
f"Archipelago ({__version__}) logging initialized"
|
||||||
|
f" on {platform.platform()}"
|
||||||
|
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def stream_input(stream, queue):
|
def stream_input(stream, queue):
|
||||||
@@ -616,10 +750,96 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
|||||||
|
|
||||||
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
|
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
|
||||||
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
|
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
|
||||||
def sorter(element: str) -> str:
|
def sorter(element: Union[str, Dict[str, Any]]) -> str:
|
||||||
|
if (not isinstance(element, str)):
|
||||||
|
element = element["title"]
|
||||||
|
|
||||||
parts = element.split(maxsplit=1)
|
parts = element.split(maxsplit=1)
|
||||||
if parts[0].lower() in ignore:
|
if parts[0].lower() in ignore:
|
||||||
return parts[1]
|
return parts[1].lower()
|
||||||
else:
|
else:
|
||||||
return element
|
return element.lower()
|
||||||
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
|
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
|
||||||
|
|
||||||
|
|
||||||
|
def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
|
||||||
|
"""Reads rom into bytearray and optionally strips off any smc header"""
|
||||||
|
buffer = bytearray(stream.read())
|
||||||
|
if strip_header and len(buffer) % 0x400 == 0x200:
|
||||||
|
return buffer[0x200:]
|
||||||
|
return buffer
|
||||||
|
|
||||||
|
|
||||||
|
_faf_tasks: "Set[asyncio.Task[typing.Any]]" = set()
|
||||||
|
|
||||||
|
|
||||||
|
def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
Use this to start a task when you don't keep a reference to it or immediately await it,
|
||||||
|
to prevent early garbage collection. "fire-and-forget"
|
||||||
|
"""
|
||||||
|
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
|
||||||
|
# Python docs:
|
||||||
|
# ```
|
||||||
|
# Important: Save a reference to the result of [asyncio.create_task],
|
||||||
|
# to avoid a task disappearing mid-execution.
|
||||||
|
# ```
|
||||||
|
# This implementation follows the pattern given in that documentation.
|
||||||
|
|
||||||
|
task: asyncio.Task[typing.Any] = asyncio.create_task(co, name=name)
|
||||||
|
_faf_tasks.add(task)
|
||||||
|
task.add_done_callback(_faf_tasks.discard)
|
||||||
|
|
||||||
|
|
||||||
|
def deprecate(message: str):
|
||||||
|
if __debug__:
|
||||||
|
raise Exception(message)
|
||||||
|
import warnings
|
||||||
|
warnings.warn(message)
|
||||||
|
|
||||||
|
def _extend_freeze_support() -> None:
|
||||||
|
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
|
||||||
|
# upstream issue: https://github.com/python/cpython/issues/76327
|
||||||
|
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
|
||||||
|
import multiprocessing
|
||||||
|
import multiprocessing.spawn
|
||||||
|
|
||||||
|
def _freeze_support() -> None:
|
||||||
|
"""Minimal freeze_support. Only apply this if frozen."""
|
||||||
|
from subprocess import _args_from_interpreter_flags
|
||||||
|
|
||||||
|
# Prevent `spawn` from trying to read `__main__` in from the main script
|
||||||
|
multiprocessing.process.ORIGINAL_DIR = None
|
||||||
|
|
||||||
|
# Handle the first process that MP will create
|
||||||
|
if (
|
||||||
|
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
|
||||||
|
'from multiprocessing.semaphore_tracker import main', # Py<3.8
|
||||||
|
'from multiprocessing.resource_tracker import main', # Py>=3.8
|
||||||
|
'from multiprocessing.forkserver import main'
|
||||||
|
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
|
||||||
|
):
|
||||||
|
exec(sys.argv[-1])
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
# Handle the second process that MP will create
|
||||||
|
if multiprocessing.spawn.is_forking(sys.argv):
|
||||||
|
kwargs = {}
|
||||||
|
for arg in sys.argv[2:]:
|
||||||
|
name, value = arg.split('=')
|
||||||
|
if value == 'None':
|
||||||
|
kwargs[name] = None
|
||||||
|
else:
|
||||||
|
kwargs[name] = int(value)
|
||||||
|
multiprocessing.spawn.spawn_main(**kwargs)
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
if not is_windows and is_frozen():
|
||||||
|
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
|
||||||
|
|
||||||
|
|
||||||
|
def freeze_support() -> None:
|
||||||
|
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
|
||||||
|
import multiprocessing
|
||||||
|
_extend_freeze_support()
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
|||||||
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()
|
||||||
12
WebHost.py
12
WebHost.py
@@ -1,5 +1,4 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import logging
|
import logging
|
||||||
import typing
|
import typing
|
||||||
@@ -12,7 +11,7 @@ ModuleUpdate.update()
|
|||||||
# in case app gets imported by something like gunicorn
|
# in case app gets imported by something like gunicorn
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
Utils.local_path.cached_path = os.path.dirname(__file__)
|
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 WebHostLib import register, app as raw_app
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
@@ -30,10 +29,15 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
|
|||||||
def get_app():
|
def get_app():
|
||||||
register()
|
register()
|
||||||
app = raw_app
|
app = raw_app
|
||||||
if os.path.exists(configpath):
|
if os.path.exists(configpath) and not app.config["TESTING"]:
|
||||||
import yaml
|
import yaml
|
||||||
app.config.from_file(configpath, yaml.safe_load)
|
app.config.from_file(configpath, yaml.safe_load)
|
||||||
logging.info(f"Updated config from {configpath}")
|
logging.info(f"Updated config from {configpath}")
|
||||||
|
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.bind(**app.config["PONY"])
|
||||||
db.generate_mapping(create_tables=True)
|
db.generate_mapping(create_tables=True)
|
||||||
return app
|
return app
|
||||||
@@ -104,7 +108,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
|||||||
for games in data:
|
for games in data:
|
||||||
if 'Archipelago' in games['gameTitle']:
|
if 'Archipelago' in games['gameTitle']:
|
||||||
generic_data = data.pop(data.index(games))
|
generic_data = data.pop(data.index(games))
|
||||||
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"].lower())
|
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
|
||||||
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
||||||
return sorted_data
|
return sorted_data
|
||||||
|
|
||||||
|
|||||||
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,16 +1,15 @@
|
|||||||
import os
|
|
||||||
import uuid
|
|
||||||
import base64
|
import base64
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import uuid
|
||||||
|
|
||||||
from pony.flask import Pony
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_caching import Cache
|
from flask_caching import Cache
|
||||||
from flask_compress import Compress
|
from flask_compress import Compress
|
||||||
|
from pony.flask import Pony
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from Utils import title_sorted
|
from Utils import title_sorted
|
||||||
from .models import *
|
|
||||||
|
|
||||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||||
LOGS_FOLDER = os.path.relpath('logs')
|
LOGS_FOLDER = os.path.relpath('logs')
|
||||||
@@ -25,6 +24,8 @@ app.jinja_env.filters['all'] = all
|
|||||||
app.config["SELFHOST"] = True # application process is in charge of running the websites
|
app.config["SELFHOST"] = True # application process is in charge of running the websites
|
||||||
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
||||||
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
|
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["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
||||||
app.config["DEBUG"] = False
|
app.config["DEBUG"] = False
|
||||||
app.config["PORT"] = 80
|
app.config["PORT"] = 80
|
||||||
@@ -32,8 +33,10 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
|||||||
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 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
|
# if you want to deploy, make sure you have a non-guessable secret key
|
||||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
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
|
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
||||||
app.config["JOB_THRESHOLD"] = 2
|
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
|
app.config['SESSION_PERMANENT'] = True
|
||||||
|
|
||||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||||
@@ -48,7 +51,7 @@ app.config["PONY"] = {
|
|||||||
app.config["MAX_ROLL"] = 20
|
app.config["MAX_ROLL"] = 20
|
||||||
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
|
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
|
||||||
app.config["JSON_AS_ASCII"] = False
|
app.config["JSON_AS_ASCII"] = False
|
||||||
app.config["PATCH_TARGET"] = "archipelago.gg"
|
app.config["HOST_ADDRESS"] = ""
|
||||||
|
|
||||||
cache = Cache(app)
|
cache = Cache(app)
|
||||||
Compress(app)
|
Compress(app)
|
||||||
@@ -73,8 +76,10 @@ def register():
|
|||||||
"""Import submodules, triggering their registering on flask routing.
|
"""Import submodules, triggering their registering on flask routing.
|
||||||
Note: initializes worlds subsystem."""
|
Note: initializes worlds subsystem."""
|
||||||
# has automatic patch integration
|
# has automatic patch integration
|
||||||
import Patch
|
import worlds.AutoWorld
|
||||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
|
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
|
from WebHostLib.customserver import run_server_process
|
||||||
# to trigger app routing picking up on it
|
# to trigger app routing picking up on it
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""API endpoints package."""
|
"""API endpoints package."""
|
||||||
from uuid import UUID
|
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from flask import Blueprint, abort
|
from flask import Blueprint, abort
|
||||||
|
|
||||||
from ..models import Room, Seed
|
|
||||||
from .. import cache
|
from .. import cache
|
||||||
|
from ..models import Room, Seed
|
||||||
|
|
||||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||||
|
|
||||||
@@ -40,9 +40,19 @@ def get_datapackage():
|
|||||||
@api_endpoints.route('/datapackage_version')
|
@api_endpoints.route('/datapackage_version')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def get_datapackage_versions():
|
def get_datapackage_versions():
|
||||||
from worlds import network_data_package, AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||||
version_package["version"] = network_data_package["version"]
|
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
|
return version_package
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import json
|
import json
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from . import api_endpoints
|
|
||||||
from flask import request, session, url_for
|
from flask import request, session, url_for
|
||||||
|
from markupsafe import Markup
|
||||||
from pony.orm import commit
|
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.check import get_yaml_data, roll_options
|
||||||
from WebHostLib.generate import get_meta
|
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'])
|
@api_endpoints.route('/generate', methods=['POST'])
|
||||||
@@ -21,13 +22,18 @@ def generate_api():
|
|||||||
if 'file' in request.files:
|
if 'file' in request.files:
|
||||||
file = request.files['file']
|
file = request.files['file']
|
||||||
options = get_yaml_data(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
|
return {"text": options}, 400
|
||||||
if "race" in request.form:
|
if "race" in request.form:
|
||||||
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
|
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
|
||||||
meta_options_source = request.form
|
meta_options_source = request.form
|
||||||
|
|
||||||
json_data = request.get_json()
|
# 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)
|
||||||
|
|
||||||
if json_data:
|
if json_data:
|
||||||
meta_options_source = json_data
|
meta_options_source = json_data
|
||||||
if 'weights' in json_data:
|
if 'weights' in json_data:
|
||||||
@@ -43,9 +49,8 @@ def generate_api():
|
|||||||
if len(options) > app.config["MAX_ROLL"]:
|
if len(options) > app.config["MAX_ROLL"]:
|
||||||
return {"text": "Max size of multiworld exceeded",
|
return {"text": "Max size of multiworld exceeded",
|
||||||
"detail": app.config["MAX_ROLL"]}, 409
|
"detail": app.config["MAX_ROLL"]}, 409
|
||||||
meta = get_meta(meta_options_source)
|
meta = get_meta(meta_options_source, race)
|
||||||
meta["race"] = race
|
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||||
results, gen_options = roll_options(options, meta["plando_options"])
|
|
||||||
if any(type(result) == str for result in results.values()):
|
if any(type(result) == str for result in results.values()):
|
||||||
return {"text": str(results),
|
return {"text": str(results),
|
||||||
"detail": results}, 400
|
"detail": results}, 400
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import session, jsonify
|
from flask import session, jsonify
|
||||||
|
from pony.orm import select
|
||||||
|
|
||||||
from WebHostLib.models import *
|
from WebHostLib.models import Room, Seed
|
||||||
from . import api_endpoints, get_players
|
from . import api_endpoints, get_players
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
import multiprocessing
|
|
||||||
import threading
|
|
||||||
from datetime import timedelta, datetime
|
|
||||||
|
|
||||||
import sys
|
import json
|
||||||
import typing
|
import logging
|
||||||
import time
|
import multiprocessing
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import typing
|
||||||
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
from pony.orm import db_session, select, commit
|
from pony.orm import db_session, select, commit
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ def autogen(config: dict):
|
|||||||
with Locker("autogen"):
|
with Locker("autogen"):
|
||||||
|
|
||||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
|
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:
|
with db_session:
|
||||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||||
|
|
||||||
@@ -177,6 +177,9 @@ class MultiworldInstance():
|
|||||||
with guardian_lock:
|
with guardian_lock:
|
||||||
multiworlds[self.room_id] = self
|
multiworlds[self.room_id] = self
|
||||||
self.ponyconfig = config["PONY"]
|
self.ponyconfig = config["PONY"]
|
||||||
|
self.cert = config["SELFLAUNCHCERT"]
|
||||||
|
self.key = config["SELFLAUNCHKEY"]
|
||||||
|
self.host = config["HOST_ADDRESS"]
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
if self.process and self.process.is_alive():
|
if self.process and self.process.is_alive():
|
||||||
@@ -184,7 +187,8 @@ class MultiworldInstance():
|
|||||||
|
|
||||||
logging.info(f"Spinning up {self.room_id}")
|
logging.info(f"Spinning up {self.room_id}")
|
||||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||||
args=(self.room_id, self.ponyconfig, get_static_server_data()),
|
args=(self.room_id, self.ponyconfig, get_static_server_data(),
|
||||||
|
self.cert, self.key, self.host),
|
||||||
name="MultiHost")
|
name="MultiHost")
|
||||||
process.start()
|
process.start()
|
||||||
# bind after start to prevent thread sync issues with guardian.
|
# bind after start to prevent thread sync issues with guardian.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import zipfile
|
import zipfile
|
||||||
from typing import *
|
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
|
from WebHostLib import app
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ def allowed_file(filename):
|
|||||||
return filename.endswith(('.txt', ".yaml", ".zip"))
|
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||||
|
|
||||||
|
|
||||||
from Generate import roll_settings, PlandoSettings
|
from Generate import roll_settings, PlandoOptions
|
||||||
from Utils import parse_yamls
|
from Utils import parse_yamls
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ def check():
|
|||||||
else:
|
else:
|
||||||
file = request.files['file']
|
file = request.files['file']
|
||||||
options = get_yaml_data(file)
|
options = get_yaml_data(file)
|
||||||
if type(options) == str:
|
if isinstance(options, str):
|
||||||
flash(options)
|
flash(options)
|
||||||
else:
|
else:
|
||||||
results, _ = roll_options(options)
|
results, _ = roll_options(options)
|
||||||
@@ -38,7 +39,7 @@ def mysterycheck():
|
|||||||
return redirect(url_for("check"), 301)
|
return redirect(url_for("check"), 301)
|
||||||
|
|
||||||
|
|
||||||
def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
|
||||||
options = {}
|
options = {}
|
||||||
# if user does not select file, browser also
|
# if user does not select file, browser also
|
||||||
# submit an empty part without filename
|
# submit an empty part without filename
|
||||||
@@ -50,9 +51,14 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
|||||||
with zipfile.ZipFile(file, 'r') as zfile:
|
with zipfile.ZipFile(file, 'r') as zfile:
|
||||||
infolist = zfile.infolist()
|
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:
|
for file in infolist:
|
||||||
if file.filename.endswith(banned_zip_contents):
|
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."
|
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")):
|
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
|
||||||
options[file.filename] = zfile.open(file, "r").read()
|
options[file.filename] = zfile.open(file, "r").read()
|
||||||
else:
|
else:
|
||||||
@@ -65,7 +71,7 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
|||||||
def roll_options(options: Dict[str, Union[dict, str]],
|
def roll_options(options: Dict[str, Union[dict, str]],
|
||||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||||
plando_options = PlandoSettings.from_set(set(plando_options))
|
plando_options = PlandoOptions.from_set(set(plando_options))
|
||||||
results = {}
|
results = {}
|
||||||
rolled_results = {}
|
rolled_results = {}
|
||||||
for filename, text in options.items():
|
for filename, text in options.items():
|
||||||
@@ -86,7 +92,7 @@ def roll_options(options: Dict[str, Union[dict, str]],
|
|||||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||||
plando_options=plando_options)
|
plando_options=plando_options)
|
||||||
except Exception as e:
|
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:
|
else:
|
||||||
results[filename] = True
|
results[filename] = True
|
||||||
return results, rolled_results
|
return results, rolled_results
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
|
||||||
import websockets
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import collections
|
||||||
|
import datetime
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
import pickle
|
||||||
|
import random
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import random
|
import typing
|
||||||
import pickle
|
|
||||||
import logging
|
import websockets
|
||||||
import datetime
|
from pony.orm import commit, db_session, select
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
from .models import db_session, Room, select, commit, Command, db
|
|
||||||
|
|
||||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
||||||
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
|
from Utils import restricted_loads, cache_argsless
|
||||||
|
from .models import Command, GameDataPackage, Room, db
|
||||||
|
|
||||||
|
|
||||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||||
@@ -49,6 +53,8 @@ class DBCommandProcessor(ServerCommandProcessor):
|
|||||||
|
|
||||||
|
|
||||||
class WebHostContext(Context):
|
class WebHostContext(Context):
|
||||||
|
room_id: int
|
||||||
|
|
||||||
def __init__(self, static_server_data: dict):
|
def __init__(self, static_server_data: dict):
|
||||||
# static server data is used during _load_game_data to load required data,
|
# 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
|
# without needing to import worlds system, which takes quite a bit of memory
|
||||||
@@ -62,6 +68,7 @@ class WebHostContext(Context):
|
|||||||
def _load_game_data(self):
|
def _load_game_data(self):
|
||||||
for key, value in self.static_server_data.items():
|
for key, value in self.static_server_data.items():
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
|
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||||
|
|
||||||
def listen_to_db_commands(self):
|
def listen_to_db_commands(self):
|
||||||
cmdprocessor = DBCommandProcessor(self)
|
cmdprocessor = DBCommandProcessor(self)
|
||||||
@@ -85,7 +92,21 @@ class WebHostContext(Context):
|
|||||||
else:
|
else:
|
||||||
self.port = get_random_port()
|
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
|
@db_session
|
||||||
def init_save(self, enabled: bool = True):
|
def init_save(self, enabled: bool = True):
|
||||||
@@ -103,7 +124,7 @@ class WebHostContext(Context):
|
|||||||
room.multisave = pickle.dumps(self.get_save())
|
room.multisave = pickle.dumps(self.get_save())
|
||||||
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
||||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||||
room.last_activity = datetime.utcnow()
|
room.last_activity = datetime.datetime.utcnow()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_save(self) -> dict:
|
def get_save(self) -> dict:
|
||||||
@@ -120,21 +141,23 @@ def get_random_port():
|
|||||||
def get_static_server_data() -> dict:
|
def get_static_server_data() -> dict:
|
||||||
import worlds
|
import worlds
|
||||||
data = {
|
data = {
|
||||||
"forced_auto_forfeits": {},
|
|
||||||
"non_hintable_names": {},
|
"non_hintable_names": {},
|
||||||
"gamespackage": worlds.network_data_package["games"],
|
"gamespackage": worlds.network_data_package["games"],
|
||||||
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
||||||
worlds.AutoWorldRegister.world_types.items()},
|
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():
|
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||||
data["forced_auto_forfeits"][world_name] = world.forced_auto_forfeit
|
|
||||||
data["non_hintable_names"][world_name] = world.hint_blacklist
|
data["non_hintable_names"][world_name] = world.hint_blacklist
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
|
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
|
# establish DB connection for multidata and multisave
|
||||||
db.bind(**ponyconfig)
|
db.bind(**ponyconfig)
|
||||||
db.generate_mapping(check_tables=False)
|
db.generate_mapping(check_tables=False)
|
||||||
@@ -144,32 +167,31 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
|
|||||||
ctx = WebHostContext(static_server_data)
|
ctx = WebHostContext(static_server_data)
|
||||||
ctx.load(room_id)
|
ctx.load(room_id)
|
||||||
ctx.init_save()
|
ctx.init_save()
|
||||||
|
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
||||||
try:
|
try:
|
||||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
|
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||||
ping_interval=None)
|
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
|
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,
|
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
||||||
ping_interval=None)
|
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
port = 0
|
port = 0
|
||||||
for wssocket in ctx.server.ws_server.sockets:
|
for wssocket in ctx.server.ws_server.sockets:
|
||||||
socketname = wssocket.getsockname()
|
socketname = wssocket.getsockname()
|
||||||
if wssocket.family == socket.AF_INET6:
|
if wssocket.family == socket.AF_INET6:
|
||||||
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
|
|
||||||
# Prefer IPv4, as most users seem to not have working ipv6 support
|
# Prefer IPv4, as most users seem to not have working ipv6 support
|
||||||
if not port:
|
if not port:
|
||||||
port = socketname[1]
|
port = socketname[1]
|
||||||
elif wssocket.family == socket.AF_INET:
|
elif wssocket.family == socket.AF_INET:
|
||||||
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
|
|
||||||
port = socketname[1]
|
port = socketname[1]
|
||||||
if port:
|
if port:
|
||||||
|
logging.info(f'Hosting game at {host}:{port}')
|
||||||
with db_session:
|
with db_session:
|
||||||
room = Room.get(id=ctx.room_id)
|
room = Room.get(id=ctx.room_id)
|
||||||
room.last_port = port
|
room.last_port = port
|
||||||
|
else:
|
||||||
|
logging.exception("Could not determine port. Likely hosting failure.")
|
||||||
with db_session:
|
with db_session:
|
||||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||||
@@ -178,4 +200,17 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
|
|||||||
|
|
||||||
from .autolauncher import Locker
|
from .autolauncher import Locker
|
||||||
with Locker(room_id):
|
with Locker(room_id):
|
||||||
asyncio.run(main())
|
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,12 +1,13 @@
|
|||||||
import zipfile
|
|
||||||
import json
|
import json
|
||||||
|
import zipfile
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from flask import send_file, Response, render_template
|
from flask import send_file, Response, render_template
|
||||||
from pony.orm import select
|
from pony.orm import select
|
||||||
|
|
||||||
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
|
from worlds.Files import AutoPatchRegister
|
||||||
from WebHostLib import app, Slot, Room, Seed, cache
|
from . import app, cache
|
||||||
|
from .models import Slot, Room, Seed
|
||||||
|
|
||||||
|
|
||||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||||
@@ -25,25 +26,23 @@ def download_patch(room_id, patch_id):
|
|||||||
with zipfile.ZipFile(filelike, "a") as zf:
|
with zipfile.ZipFile(filelike, "a") as zf:
|
||||||
with zf.open("archipelago.json", "r") as f:
|
with zf.open("archipelago.json", "r") as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None
|
manifest["server"] = f"{app.config['HOST_ADDRESS']}:{last_port}" if last_port else None
|
||||||
with zipfile.ZipFile(new_file, "w") as new_zip:
|
with zipfile.ZipFile(new_file, "w") as new_zip:
|
||||||
for file in zf.infolist():
|
for file in zf.infolist():
|
||||||
if file.filename == "archipelago.json":
|
if file.filename == "archipelago.json":
|
||||||
new_zip.writestr("archipelago.json", json.dumps(manifest))
|
new_zip.writestr("archipelago.json", json.dumps(manifest))
|
||||||
else:
|
else:
|
||||||
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
|
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)}" \
|
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \
|
||||||
f"{AutoPatchRegister.patch_types[patch.game].patch_file_ending}"
|
f"{patch_file_ending}"
|
||||||
new_file.seek(0)
|
new_file.seek(0)
|
||||||
return send_file(new_file, as_attachment=True, download_name=fname)
|
return send_file(new_file, as_attachment=True, download_name=fname)
|
||||||
else:
|
else:
|
||||||
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
return "Old Patch file, no longer compatible."
|
||||||
patch_data = BytesIO(patch_data)
|
|
||||||
|
|
||||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
|
||||||
f"{preferred_endings[patch.game]}"
|
|
||||||
return send_file(patch_data, as_attachment=True, download_name=fname)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/dl_spoiler/<suuid:seed_id>")
|
@app.route("/dl_spoiler/<suuid:seed_id>")
|
||||||
@@ -65,7 +64,7 @@ def download_slot_file(room_id, player_id: int):
|
|||||||
if slot_data.game == "Minecraft":
|
if slot_data.game == "Minecraft":
|
||||||
from worlds.minecraft import mc_update_output
|
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"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
||||||
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
|
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)
|
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
|
||||||
elif slot_data.game == "Factorio":
|
elif slot_data.game == "Factorio":
|
||||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||||
@@ -73,13 +72,24 @@ def download_slot_file(room_id, player_id: int):
|
|||||||
if name.endswith("info.json"):
|
if name.endswith("info.json"):
|
||||||
fname = name.rsplit("/", 1)[0] + ".zip"
|
fname = name.rsplit("/", 1)[0] + ".zip"
|
||||||
elif slot_data.game == "Ocarina of Time":
|
elif slot_data.game == "Ocarina of Time":
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
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":
|
elif slot_data.game == "VVVVVV":
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
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":
|
elif slot_data.game == "Super Mario 64":
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
||||||
elif slot_data.game == "Dark Souls III":
|
elif slot_data.game == "Dark Souls III":
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
|
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:
|
else:
|
||||||
return "Game download not supported."
|
return "Game download not supported."
|
||||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
|
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import random
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
import random
|
||||||
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
|
import concurrent.futures
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Dict, Optional, Any
|
from typing import Dict, Optional, Any, Union, List
|
||||||
from Utils import __version__
|
|
||||||
|
|
||||||
from flask import request, flash, redirect, url_for, session, render_template
|
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 Main import main as ERmain
|
|
||||||
from BaseClasses import seeddigits, get_seed
|
from BaseClasses import seeddigits, get_seed
|
||||||
from Generate import handle_name, PlandoSettings
|
from Generate import handle_name, PlandoOptions
|
||||||
import pickle
|
from Main import main as ERmain
|
||||||
|
from Utils import __version__
|
||||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
|
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
|
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||||
from .check import get_yaml_data, roll_options
|
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
|
from .upload import upload_zip_to_db
|
||||||
|
|
||||||
|
|
||||||
def get_meta(options_source: dict) -> dict:
|
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
|
||||||
plando_options = {
|
plando_options = {
|
||||||
options_source.get("plando_bosses", ""),
|
options_source.get("plando_bosses", ""),
|
||||||
options_source.get("plando_items", ""),
|
options_source.get("plando_items", ""),
|
||||||
@@ -32,13 +33,27 @@ def get_meta(options_source: dict) -> dict:
|
|||||||
|
|
||||||
server_options = {
|
server_options = {
|
||||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||||
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
|
"release_mode": options_source.get("release_mode", "goal"),
|
||||||
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
||||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||||
"server_password": options_source.get("server_password", None),
|
"server_password": options_source.get("server_password", None),
|
||||||
}
|
}
|
||||||
return {"server_options": server_options, "plando_options": list(plando_options)}
|
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'])
|
@app.route('/generate', methods=['GET', 'POST'])
|
||||||
@@ -51,16 +66,11 @@ def generate(race=False):
|
|||||||
else:
|
else:
|
||||||
file = request.files['file']
|
file = request.files['file']
|
||||||
options = get_yaml_data(file)
|
options = get_yaml_data(file)
|
||||||
if type(options) == str:
|
if isinstance(options, str):
|
||||||
flash(options)
|
flash(options)
|
||||||
else:
|
else:
|
||||||
meta = get_meta(request.form)
|
meta = get_meta(request.form, race)
|
||||||
meta["race"] = race
|
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||||
results, gen_options = roll_options(options, meta["plando_options"])
|
|
||||||
|
|
||||||
if race:
|
|
||||||
meta["server_options"]["item_cheat"] = False
|
|
||||||
meta["server_options"]["remaining_mode"] = "disabled"
|
|
||||||
|
|
||||||
if any(type(result) == str for result in results.values()):
|
if any(type(result) == str for result in results.values()):
|
||||||
return render_template("checkResult.html", results=results)
|
return render_template("checkResult.html", results=results)
|
||||||
@@ -91,14 +101,14 @@ def generate(race=False):
|
|||||||
return render_template("generate.html", race=race, version=__version__)
|
return render_template("generate.html", race=race, version=__version__)
|
||||||
|
|
||||||
|
|
||||||
def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||||
if not meta:
|
if not meta:
|
||||||
meta: Dict[str, Any] = {}
|
meta: Dict[str, Any] = {}
|
||||||
|
|
||||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||||
race = meta.setdefault("race", False)
|
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
||||||
|
|
||||||
try:
|
def task():
|
||||||
target = tempfile.TemporaryDirectory()
|
target = tempfile.TemporaryDirectory()
|
||||||
playercount = len(gen_options)
|
playercount = len(gen_options)
|
||||||
seed = get_seed()
|
seed = get_seed()
|
||||||
@@ -113,13 +123,13 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
|
|||||||
erargs = parse_arguments(['--multi', str(playercount)])
|
erargs = parse_arguments(['--multi', str(playercount)])
|
||||||
erargs.seed = seed
|
erargs.seed = seed
|
||||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||||
erargs.spoiler = 0 if race else 2
|
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
|
||||||
erargs.race = race
|
erargs.race = race
|
||||||
erargs.outputname = seedname
|
erargs.outputname = seedname
|
||||||
erargs.outputpath = target.name
|
erargs.outputpath = target.name
|
||||||
erargs.teams = 1
|
erargs.teams = 1
|
||||||
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options",
|
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||||
{"bosses", "items", "connections", "texts"}))
|
{"bosses", "items", "connections", "texts"}))
|
||||||
|
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||||
@@ -138,6 +148,23 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
|
|||||||
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
||||||
|
|
||||||
return upload_to_db(target.name, sid, owner, race)
|
return upload_to_db(target.name, sid, owner, race)
|
||||||
|
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||||
|
thread = thread_pool.submit(task)
|
||||||
|
|
||||||
|
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:
|
except BaseException as e:
|
||||||
if sid:
|
if sid:
|
||||||
with db_session:
|
with db_session:
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
|
from pony.orm import count
|
||||||
|
|
||||||
from WebHostLib import app, cache
|
from WebHostLib import app, cache
|
||||||
from .models import *
|
from .models import Room, Seed
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
@app.route('/', methods=['GET', 'POST'])
|
@app.route('/', methods=['GET', 'POST'])
|
||||||
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def update_sprites_lttp():
|
|||||||
|
|
||||||
spriteData = []
|
spriteData = []
|
||||||
|
|
||||||
for file in os.listdir(input_dir):
|
for file in (file for file in os.listdir(input_dir) if not file.startswith(".")):
|
||||||
sprite = Sprite(os.path.join(input_dir, file))
|
sprite = Sprite(os.path.join(input_dir, file))
|
||||||
|
|
||||||
if not sprite.name:
|
if not sprite.name:
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
|
from typing import List, Dict, Union
|
||||||
|
|
||||||
import jinja2.exceptions
|
import jinja2.exceptions
|
||||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||||
|
from pony.orm import count, commit, db_session
|
||||||
|
|
||||||
from .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
|
from .models import Seed, Room, Command, UUID, uuid4
|
||||||
|
|
||||||
|
|
||||||
def get_world_theme(game_name: str):
|
def get_world_theme(game_name: str):
|
||||||
@@ -68,10 +70,6 @@ def tutorial(game, file, lang):
|
|||||||
|
|
||||||
@app.route('/tutorial/')
|
@app.route('/tutorial/')
|
||||||
def tutorial_landing():
|
def tutorial_landing():
|
||||||
worlds = {}
|
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
|
||||||
if not world.hidden:
|
|
||||||
worlds[game] = world
|
|
||||||
return render_template("tutorialLanding.html")
|
return render_template("tutorialLanding.html")
|
||||||
|
|
||||||
|
|
||||||
@@ -118,7 +116,11 @@ def display_log(room: UUID):
|
|||||||
if room is None:
|
if room is None:
|
||||||
return abort(404)
|
return abort(404)
|
||||||
if room.owner == session["_id"]:
|
if room.owner == session["_id"]:
|
||||||
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
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
|
return "Access Denied", 403
|
||||||
|
|
||||||
|
|
||||||
@@ -151,7 +153,7 @@ def favicon():
|
|||||||
|
|
||||||
@app.route('/discord')
|
@app.route('/discord')
|
||||||
def discord():
|
def discord():
|
||||||
return redirect("https://discord.gg/archipelago")
|
return redirect("https://discord.gg/8Z65BR2")
|
||||||
|
|
||||||
|
|
||||||
@app.route('/datapackage')
|
@app.route('/datapackage')
|
||||||
@@ -166,8 +168,9 @@ def get_datapackage():
|
|||||||
@app.route('/index')
|
@app.route('/index')
|
||||||
@app.route('/sitemap')
|
@app.route('/sitemap')
|
||||||
def get_sitemap():
|
def get_sitemap():
|
||||||
available_games = []
|
available_games: List[Dict[str, Union[str, bool]]] = []
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
if not world.hidden:
|
if not world.hidden:
|
||||||
available_games.append(game)
|
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)
|
return render_template("siteMap.html", games=available_games)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
from pony.orm import *
|
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
|
||||||
|
|
||||||
db = Database()
|
db = Database()
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ class Slot(db.Entity):
|
|||||||
class Room(db.Entity):
|
class Room(db.Entity):
|
||||||
id = PrimaryKey(UUID, default=uuid4)
|
id = PrimaryKey(UUID, default=uuid4)
|
||||||
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
|
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)
|
owner = Required(UUID, index=True)
|
||||||
commands = Set('Command')
|
commands = Set('Command')
|
||||||
seed = Required('Seed', index=True)
|
seed = Required('Seed', index=True)
|
||||||
@@ -29,6 +29,7 @@ class Room(db.Entity):
|
|||||||
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
||||||
timeout = Required(int, default=lambda: 2 * 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)
|
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)
|
last_port = Optional(int, default=lambda: 0)
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ class Seed(db.Entity):
|
|||||||
rooms = Set(Room)
|
rooms = Set(Room)
|
||||||
multidata = Required(bytes, lazy=True)
|
multidata = Required(bytes, lazy=True)
|
||||||
owner = Required(UUID, index=True)
|
owner = Required(UUID, 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
|
||||||
slots = Set(Slot)
|
slots = Set(Slot)
|
||||||
spoiler = Optional(LongStr, lazy=True)
|
spoiler = Optional(LongStr, lazy=True)
|
||||||
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
|
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
|
||||||
@@ -55,3 +56,8 @@ class Generation(db.Entity):
|
|||||||
options = Required(buffer, lazy=True)
|
options = Required(buffer, lazy=True)
|
||||||
meta = Required(LongStr, default=lambda: "{\"race\": false}")
|
meta = Required(LongStr, default=lambda: "{\"race\": false}")
|
||||||
state = Required(int, default=0, index=True)
|
state = Required(int, default=0, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GameDataPackage(db.Entity):
|
||||||
|
checksum = PrimaryKey(str)
|
||||||
|
data = Required(bytes)
|
||||||
|
|||||||
@@ -1,53 +1,29 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from Utils import __version__
|
|
||||||
from jinja2 import Template
|
|
||||||
import yaml
|
|
||||||
import json
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
import yaml
|
||||||
import Options
|
from jinja2 import Template
|
||||||
|
|
||||||
target_folder = os.path.join("WebHostLib", "static", "generated")
|
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",
|
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||||
"exclude_locations"}
|
"exclude_locations", "priority_locations"}
|
||||||
|
|
||||||
|
|
||||||
def create():
|
def create():
|
||||||
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
|
target_folder = local_path("WebHostLib", "static", "generated")
|
||||||
|
yaml_folder = os.path.join(target_folder, "configs")
|
||||||
|
|
||||||
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
|
Options.generate_yaml_templates(yaml_folder)
|
||||||
data = {}
|
|
||||||
special = getattr(option, "special_range_cutoff", None)
|
|
||||||
if special is not None:
|
|
||||||
data[special] = 0
|
|
||||||
data.update({
|
|
||||||
option.range_start: 0,
|
|
||||||
option.range_end: 0,
|
|
||||||
"random": 0, "random-low": 0, "random-high": 0,
|
|
||||||
option.default: 50
|
|
||||||
})
|
|
||||||
notes = {
|
|
||||||
special: "minimum value without special meaning",
|
|
||||||
option.range_start: "minimum value",
|
|
||||||
option.range_end: "maximum value"
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, number in getattr(option, "special_range_names", {}).items():
|
def get_html_doc(option_type: type(Options.Option)) -> str:
|
||||||
if number in data:
|
if not option_type.__doc__:
|
||||||
data[name] = data[number]
|
return "Please document me!"
|
||||||
del data[number]
|
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
|
||||||
else:
|
|
||||||
data[name] = 0
|
|
||||||
|
|
||||||
return data, notes
|
|
||||||
|
|
||||||
def default_converter(default_value):
|
|
||||||
if isinstance(default_value, (set, frozenset)):
|
|
||||||
return list(default_value)
|
|
||||||
return default_value
|
|
||||||
|
|
||||||
weighted_settings = {
|
weighted_settings = {
|
||||||
"baseOptions": {
|
"baseOptions": {
|
||||||
@@ -60,20 +36,15 @@ def create():
|
|||||||
|
|
||||||
for game_name, world in AutoWorldRegister.world_types.items():
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
|
|
||||||
all_options = {**Options.per_game_common_options, **world.option_definitions}
|
all_options: typing.Dict[str, Options.AssembleOptions] = {
|
||||||
res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
|
**Options.per_game_common_options,
|
||||||
options=all_options,
|
**world.option_definitions
|
||||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
}
|
||||||
dictify_range=dictify_range, default_converter=default_converter,
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
|
|
||||||
f.write(res)
|
|
||||||
|
|
||||||
# Generate JSON files for player-settings pages
|
# Generate JSON files for player-settings pages
|
||||||
player_settings = {
|
player_settings = {
|
||||||
"baseOptions": {
|
"baseOptions": {
|
||||||
"description": "Generated by https://archipelago.gg/",
|
"description": f"Generated by https://archipelago.gg/ for {game_name}",
|
||||||
"game": game_name,
|
"game": game_name,
|
||||||
"name": "Player",
|
"name": "Player",
|
||||||
},
|
},
|
||||||
@@ -84,37 +55,32 @@ def create():
|
|||||||
if option_name in handled_in_js:
|
if option_name in handled_in_js:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
elif option.options:
|
elif issubclass(option, Options.Choice) or issubclass(option, Options.Toggle):
|
||||||
game_options[option_name] = this_option = {
|
game_options[option_name] = this_option = {
|
||||||
"type": "select",
|
"type": "select",
|
||||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
"description": get_html_doc(option),
|
||||||
"defaultValue": None,
|
"defaultValue": None,
|
||||||
"options": []
|
"options": []
|
||||||
}
|
}
|
||||||
|
|
||||||
for sub_option_id, sub_option_name in option.name_lookup.items():
|
for sub_option_id, sub_option_name in option.name_lookup.items():
|
||||||
this_option["options"].append({
|
if sub_option_name != "random":
|
||||||
"name": option.get_option_name(sub_option_id),
|
this_option["options"].append({
|
||||||
"value": sub_option_name,
|
"name": option.get_option_name(sub_option_id),
|
||||||
})
|
"value": sub_option_name,
|
||||||
|
})
|
||||||
if sub_option_id == option.default:
|
if sub_option_id == option.default:
|
||||||
this_option["defaultValue"] = sub_option_name
|
this_option["defaultValue"] = sub_option_name
|
||||||
|
|
||||||
this_option["options"].append({
|
if not this_option["defaultValue"]:
|
||||||
"name": "Random",
|
|
||||||
"value": "random",
|
|
||||||
})
|
|
||||||
|
|
||||||
if option.default == "random":
|
|
||||||
this_option["defaultValue"] = "random"
|
this_option["defaultValue"] = "random"
|
||||||
|
|
||||||
elif issubclass(option, Options.Range):
|
elif issubclass(option, Options.Range):
|
||||||
game_options[option_name] = {
|
game_options[option_name] = {
|
||||||
"type": "range",
|
"type": "range",
|
||||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
"description": get_html_doc(option),
|
||||||
"defaultValue": option.default if hasattr(
|
"defaultValue": option.default if hasattr(
|
||||||
option, "default") and option.default != "random" else option.range_start,
|
option, "default") and option.default != "random" else option.range_start,
|
||||||
"min": option.range_start,
|
"min": option.range_start,
|
||||||
@@ -127,27 +93,30 @@ def create():
|
|||||||
for key, val in option.special_range_names.items():
|
for key, val in option.special_range_names.items():
|
||||||
game_options[option_name]["value_names"][key] = val
|
game_options[option_name]["value_names"][key] = val
|
||||||
|
|
||||||
elif getattr(option, "verify_item_name", False):
|
elif issubclass(option, Options.ItemSet):
|
||||||
game_options[option_name] = {
|
game_options[option_name] = {
|
||||||
"type": "items-list",
|
"type": "items-list",
|
||||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
"description": get_html_doc(option),
|
||||||
|
"defaultValue": list(option.default)
|
||||||
}
|
}
|
||||||
|
|
||||||
elif getattr(option, "verify_location_name", False):
|
elif issubclass(option, Options.LocationSet):
|
||||||
game_options[option_name] = {
|
game_options[option_name] = {
|
||||||
"type": "locations-list",
|
"type": "locations-list",
|
||||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
"description": get_html_doc(option),
|
||||||
|
"defaultValue": list(option.default)
|
||||||
}
|
}
|
||||||
|
|
||||||
elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet):
|
elif issubclass(option, Options.VerifyKeys) and not issubclass(option, Options.OptionDict):
|
||||||
if option.valid_keys:
|
if option.valid_keys:
|
||||||
game_options[option_name] = {
|
game_options[option_name] = {
|
||||||
"type": "custom-list",
|
"type": "custom-list",
|
||||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
"description": get_html_doc(option),
|
||||||
"options": list(option.valid_keys),
|
"options": list(option.valid_keys),
|
||||||
|
"defaultValue": list(option.default) if hasattr(option, "default") else []
|
||||||
}
|
}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -161,6 +130,14 @@ def create():
|
|||||||
json.dump(player_settings, f, indent=2, separators=(',', ': '))
|
json.dump(player_settings, f, indent=2, separators=(',', ': '))
|
||||||
|
|
||||||
if not world.hidden and world.web.settings_page is True:
|
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["baseOptions"]["game"][game_name] = 0
|
||||||
weighted_settings["games"][game_name] = {}
|
weighted_settings["games"][game_name] = {}
|
||||||
weighted_settings["games"][game_name]["gameSettings"] = game_options
|
weighted_settings["games"][game_name]["gameSettings"] = game_options
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
flask>=2.1.3
|
flask>=2.2.3
|
||||||
pony>=0.7.16
|
pony>=0.7.16; python_version <= '3.10'
|
||||||
waitress>=2.1.1
|
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'
|
||||||
Flask-Caching>=2.0.1
|
waitress>=2.1.2
|
||||||
Flask-Compress>=1.12
|
Flask-Caching>=2.0.2
|
||||||
Flask-Limiter>=2.5.0
|
Flask-Compress>=1.13
|
||||||
bokeh>=2.4.3
|
Flask-Limiter>=3.3.0
|
||||||
|
bokeh>=3.1.1
|
||||||
|
markupsafe>=2.1.3
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ window.addEventListener('load', () => {
|
|||||||
"ordering": true,
|
"ordering": true,
|
||||||
"info": false,
|
"info": false,
|
||||||
"dom": "t",
|
"dom": "t",
|
||||||
|
"stateSave": true,
|
||||||
});
|
});
|
||||||
console.log(tables);
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
|
|||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||||
for (let i=0; i < headers.length; i++){
|
header.setAttribute('id', headerId);
|
||||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
header.addEventListener('click', () => {
|
||||||
headers[i].setAttribute('id', headerId);
|
window.location.hash = `#${headerId}`;
|
||||||
headers[i].addEventListener('click', () =>
|
header.scrollIntoView();
|
||||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
if (scrollTargetIndex > -1) {
|
document.fonts.ready.finally(() => {
|
||||||
try{
|
if (window.location.hash) {
|
||||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
scrollTarget?.scrollIntoView();
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
tutorialWrapper.innerHTML =
|
tutorialWrapper.innerHTML =
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ comfortable exploiting certain glitches in the game.
|
|||||||
## What is a multi-world?
|
## 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
|
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a
|
||||||
two player multi-world, players A and B each get their own randomized version of a game, called seeds. In each player's
|
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
|
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.
|
item will be sent to player B's world over the internet.
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ their game.
|
|||||||
|
|
||||||
## What happens if a person has to leave early?
|
## What happens if a person has to leave early?
|
||||||
|
|
||||||
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all the
|
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.
|
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?
|
## What does multi-game mean?
|
||||||
@@ -46,7 +46,7 @@ the website is not required to generate them.
|
|||||||
## How do I get started?
|
## 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
|
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
||||||
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
|
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
|
||||||
any questions you might have.
|
any questions you might have.
|
||||||
|
|
||||||
## What are some common terms I should know?
|
## What are some common terms I should know?
|
||||||
|
|||||||
@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
|
|||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||||
for (let i=0; i < headers.length; i++){
|
header.setAttribute('id', headerId);
|
||||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
header.addEventListener('click', () => {
|
||||||
headers[i].setAttribute('id', headerId);
|
window.location.hash = `#${headerId}`;
|
||||||
headers[i].addEventListener('click', () =>
|
header.scrollIntoView();
|
||||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
if (scrollTargetIndex > -1) {
|
document.fonts.ready.finally(() => {
|
||||||
try{
|
if (window.location.hash) {
|
||||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
scrollTarget?.scrollIntoView();
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
gameInfo.innerHTML =
|
gameInfo.innerHTML =
|
||||||
|
|||||||
@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
|
|||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||||
for (let i=0; i < headers.length; i++){
|
header.setAttribute('id', headerId);
|
||||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
header.addEventListener('click', () => {
|
||||||
headers[i].setAttribute('id', headerId);
|
window.location.hash = `#${headerId}`;
|
||||||
headers[i].addEventListener('click', () =>
|
header.scrollIntoView();
|
||||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
if (scrollTargetIndex > -1) {
|
document.fonts.ready.finally(() => {
|
||||||
try{
|
if (window.location.hash) {
|
||||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
scrollTarget?.scrollIntoView();
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
tutorialWrapper.innerHTML =
|
tutorialWrapper.innerHTML =
|
||||||
|
|||||||
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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -102,9 +102,15 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
// td Left
|
// td Left
|
||||||
const tdl = document.createElement('td');
|
const tdl = document.createElement('td');
|
||||||
const label = document.createElement('label');
|
const label = document.createElement('label');
|
||||||
|
label.textContent = `${settings[setting].displayName}: `;
|
||||||
label.setAttribute('for', setting);
|
label.setAttribute('for', setting);
|
||||||
label.setAttribute('data-tooltip', settings[setting].description);
|
|
||||||
label.innerText = `${settings[setting].displayName}:`;
|
const questionSpan = document.createElement('span');
|
||||||
|
questionSpan.classList.add('interactive');
|
||||||
|
questionSpan.setAttribute('data-tooltip', settings[setting].description);
|
||||||
|
questionSpan.innerText = '(?)';
|
||||||
|
|
||||||
|
label.appendChild(questionSpan);
|
||||||
tdl.appendChild(label);
|
tdl.appendChild(label);
|
||||||
tr.appendChild(tdl);
|
tr.appendChild(tdl);
|
||||||
|
|
||||||
@@ -112,6 +118,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
const tdr = document.createElement('td');
|
const tdr = document.createElement('td');
|
||||||
let element = null;
|
let element = null;
|
||||||
|
|
||||||
|
const randomButton = document.createElement('button');
|
||||||
|
|
||||||
switch(settings[setting].type){
|
switch(settings[setting].type){
|
||||||
case 'select':
|
case 'select':
|
||||||
element = document.createElement('div');
|
element = document.createElement('div');
|
||||||
@@ -132,8 +140,21 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
}
|
}
|
||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
select.addEventListener('change', (event) => updateGameSetting(event));
|
select.addEventListener('change', (event) => updateGameSetting(event.target));
|
||||||
element.appendChild(select);
|
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;
|
break;
|
||||||
|
|
||||||
case 'range':
|
case 'range':
|
||||||
@@ -148,15 +169,29 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
range.value = currentSettings[gameName][setting];
|
range.value = currentSettings[gameName][setting];
|
||||||
range.addEventListener('change', (event) => {
|
range.addEventListener('change', (event) => {
|
||||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
updateGameSetting(event);
|
updateGameSetting(event.target);
|
||||||
});
|
});
|
||||||
element.appendChild(range);
|
element.appendChild(range);
|
||||||
|
|
||||||
let rangeVal = document.createElement('span');
|
let rangeVal = document.createElement('span');
|
||||||
rangeVal.classList.add('range-value');
|
rangeVal.classList.add('range-value');
|
||||||
rangeVal.setAttribute('id', `${setting}-value`);
|
rangeVal.setAttribute('id', `${setting}-value`);
|
||||||
rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
||||||
|
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
||||||
element.appendChild(rangeVal);
|
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;
|
break;
|
||||||
|
|
||||||
case 'special_range':
|
case 'special_range':
|
||||||
@@ -170,6 +205,11 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
let presetOption = document.createElement('option');
|
let presetOption = document.createElement('option');
|
||||||
presetOption.innerText = presetName;
|
presetOption.innerText = presetName;
|
||||||
presetOption.value = settings[setting].value_names[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);
|
specialRangeSelect.appendChild(presetOption);
|
||||||
});
|
});
|
||||||
let customOption = document.createElement('option');
|
let customOption = document.createElement('option');
|
||||||
@@ -195,7 +235,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
let specialRangeVal = document.createElement('span');
|
let specialRangeVal = document.createElement('span');
|
||||||
specialRangeVal.classList.add('range-value');
|
specialRangeVal.classList.add('range-value');
|
||||||
specialRangeVal.setAttribute('id', `${setting}-value`);
|
specialRangeVal.setAttribute('id', `${setting}-value`);
|
||||||
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
||||||
|
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
||||||
|
|
||||||
// Configure select event listener
|
// Configure select event listener
|
||||||
specialRangeSelect.addEventListener('change', (event) => {
|
specialRangeSelect.addEventListener('change', (event) => {
|
||||||
@@ -204,7 +245,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
// Update range slider
|
// Update range slider
|
||||||
specialRange.value = event.target.value;
|
specialRange.value = event.target.value;
|
||||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
updateGameSetting(event);
|
updateGameSetting(event.target);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configure range event handler
|
// Configure range event handler
|
||||||
@@ -214,13 +255,29 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
||||||
parseInt(event.target.value) : 'custom';
|
parseInt(event.target.value) : 'custom';
|
||||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||||
updateGameSetting(event);
|
updateGameSetting(event.target);
|
||||||
});
|
});
|
||||||
|
|
||||||
element.appendChild(specialRangeSelect);
|
element.appendChild(specialRangeSelect);
|
||||||
specialRangeWrapper.appendChild(specialRange);
|
specialRangeWrapper.appendChild(specialRange);
|
||||||
specialRangeWrapper.appendChild(specialRangeVal);
|
specialRangeWrapper.appendChild(specialRangeVal);
|
||||||
element.appendChild(specialRangeWrapper);
|
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;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -237,6 +294,27 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
|||||||
return table;
|
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 updateBaseSetting = (event) => {
|
||||||
const options = JSON.parse(localStorage.getItem(gameName));
|
const options = JSON.parse(localStorage.getItem(gameName));
|
||||||
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||||
@@ -244,10 +322,17 @@ const updateBaseSetting = (event) => {
|
|||||||
localStorage.setItem(gameName, JSON.stringify(options));
|
localStorage.setItem(gameName, JSON.stringify(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateGameSetting = (event) => {
|
const updateGameSetting = (settingElement) => {
|
||||||
const options = JSON.parse(localStorage.getItem(gameName));
|
const options = JSON.parse(localStorage.getItem(gameName));
|
||||||
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
|
||||||
event.target.value : parseInt(event.target.value, 10);
|
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));
|
localStorage.setItem(gameName, JSON.stringify(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -281,6 +366,7 @@ const generateGame = (raceMode = false) => {
|
|||||||
weights: { player: settings },
|
weights: { player: settings },
|
||||||
presetData: { player: settings },
|
presetData: { player: settings },
|
||||||
playerCount: 1,
|
playerCount: 1,
|
||||||
|
spoiler: 3,
|
||||||
race: raceMode ? '1' : '0',
|
race: raceMode ? '1' : '0',
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
window.location.href = response.data.url;
|
window.location.href = response.data.url;
|
||||||
|
|||||||
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
const adjustTableHeight = () => {
|
const adjustTableHeight = () => {
|
||||||
const tablesContainer = document.getElementById('tables-container');
|
const tablesContainer = document.getElementById('tables-container');
|
||||||
|
if (!tablesContainer)
|
||||||
|
return;
|
||||||
const upperDistance = tablesContainer.getBoundingClientRect().top;
|
const upperDistance = tablesContainer.getBoundingClientRect().top;
|
||||||
|
|
||||||
const containerHeight = window.innerHeight - upperDistance;
|
const containerHeight = window.innerHeight - upperDistance;
|
||||||
@@ -17,6 +19,14 @@ window.addEventListener('load', () => {
|
|||||||
paging: false,
|
paging: false,
|
||||||
info: false,
|
info: false,
|
||||||
dom: "t",
|
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: [
|
columnDefs: [
|
||||||
{
|
{
|
||||||
targets: 'hours',
|
targets: 'hours',
|
||||||
@@ -63,16 +73,44 @@ window.addEventListener('load', () => {
|
|||||||
// the tbody and render two separate tables.
|
// the tbody and render two separate tables.
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('search').addEventListener('keyup', (event) => {
|
const searchBox = document.getElementById("search");
|
||||||
tables.search(event.target.value);
|
searchBox.value = tables.search();
|
||||||
console.info(tables.search());
|
searchBox.focus();
|
||||||
|
searchBox.select();
|
||||||
|
const doSearch = () => {
|
||||||
|
tables.search(searchBox.value);
|
||||||
tables.draw();
|
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 update = () => {
|
||||||
const target = $("<div></div>");
|
const target = $("<div></div>");
|
||||||
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
console.log("Updating Tracker...");
|
||||||
target.load("/tracker/" + tracker, function (response, status) {
|
target.load(location.href, function (response, status) {
|
||||||
if (status === "success") {
|
if (status === "success") {
|
||||||
target.find(".table").each(function (i, new_table) {
|
target.find(".table").each(function (i, new_table) {
|
||||||
const new_trs = $(new_table).find("tbody>tr");
|
const new_trs = $(new_table).find("tbody>tr");
|
||||||
@@ -90,19 +128,14 @@ window.addEventListener('load', () => {
|
|||||||
console.log(response);
|
console.log(response);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||||
}
|
}
|
||||||
|
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||||
setInterval(update, 30000);
|
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
adjustTableHeight();
|
adjustTableHeight();
|
||||||
tables.draw();
|
tables.draw();
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".table-wrapper").scrollsync({
|
|
||||||
y_sync: true,
|
|
||||||
x_sync: true
|
|
||||||
});
|
|
||||||
|
|
||||||
adjustTableHeight();
|
adjustTableHeight();
|
||||||
});
|
});
|
||||||
@@ -27,25 +27,28 @@ window.addEventListener('load', () => {
|
|||||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||||
adjustHeaderWidth();
|
adjustHeaderWidth();
|
||||||
|
|
||||||
|
const title = document.querySelector('h1')
|
||||||
|
if (title) {
|
||||||
|
document.title = title.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
// Reset the id of all header divs to something nicer
|
||||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||||
for (let i=0; i < headers.length; i++){
|
header.setAttribute('id', headerId);
|
||||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
header.addEventListener('click', () => {
|
||||||
headers[i].setAttribute('id', headerId);
|
window.location.hash = `#${headerId}`;
|
||||||
headers[i].addEventListener('click', () =>
|
header.scrollIntoView();
|
||||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||||
if (scrollTargetIndex > -1) {
|
document.fonts.ready.finally(() => {
|
||||||
try{
|
if (window.location.hash) {
|
||||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
scrollTarget?.scrollIntoView();
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
tutorialWrapper.innerHTML =
|
tutorialWrapper.innerHTML =
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ window.addEventListener('load', () => {
|
|||||||
"order": [[ 3, "desc" ]],
|
"order": [[ 3, "desc" ]],
|
||||||
"info": false,
|
"info": false,
|
||||||
"dom": "t",
|
"dom": "t",
|
||||||
|
"stateSave": true,
|
||||||
});
|
});
|
||||||
$("#seeds-table").DataTable({
|
$("#seeds-table").DataTable({
|
||||||
"paging": false,
|
"paging": false,
|
||||||
@@ -13,5 +14,6 @@ window.addEventListener('load', () => {
|
|||||||
"order": [[ 2, "desc" ]],
|
"order": [[ 2, "desc" ]],
|
||||||
"info": false,
|
"info": false,
|
||||||
"dom": "t",
|
"dom": "t",
|
||||||
|
"stateSave": true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,19 +78,20 @@ const createDefaultSettings = (settingData) => {
|
|||||||
break;
|
break;
|
||||||
case 'range':
|
case 'range':
|
||||||
case 'special_range':
|
case 'special_range':
|
||||||
for (let i = setting.min; i <= setting.max; ++i){
|
|
||||||
newSettings[game][gameSetting][i] =
|
|
||||||
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
|
|
||||||
}
|
|
||||||
newSettings[game][gameSetting]['random'] = 0;
|
newSettings[game][gameSetting]['random'] = 0;
|
||||||
newSettings[game][gameSetting]['random-low'] = 0;
|
newSettings[game][gameSetting]['random-low'] = 0;
|
||||||
newSettings[game][gameSetting]['random-high'] = 0;
|
newSettings[game][gameSetting]['random-high'] = 0;
|
||||||
|
if (setting.hasOwnProperty('defaultValue')) {
|
||||||
|
newSettings[game][gameSetting][setting.defaultValue] = 25;
|
||||||
|
} else {
|
||||||
|
newSettings[game][gameSetting][setting.min] = 25;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'items-list':
|
case 'items-list':
|
||||||
case 'locations-list':
|
case 'locations-list':
|
||||||
case 'custom-list':
|
case 'custom-list':
|
||||||
newSettings[game][gameSetting] = [];
|
newSettings[game][gameSetting] = setting.defaultValue;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -100,6 +101,7 @@ const createDefaultSettings = (settingData) => {
|
|||||||
|
|
||||||
newSettings[game].start_inventory = {};
|
newSettings[game].start_inventory = {};
|
||||||
newSettings[game].exclude_locations = [];
|
newSettings[game].exclude_locations = [];
|
||||||
|
newSettings[game].priority_locations = [];
|
||||||
newSettings[game].local_items = [];
|
newSettings[game].local_items = [];
|
||||||
newSettings[game].non_local_items = [];
|
newSettings[game].non_local_items = [];
|
||||||
newSettings[game].start_hints = [];
|
newSettings[game].start_hints = [];
|
||||||
@@ -135,21 +137,28 @@ const buildUI = (settingData) => {
|
|||||||
expandButton.classList.add('invisible');
|
expandButton.classList.add('invisible');
|
||||||
gameDiv.appendChild(expandButton);
|
gameDiv.appendChild(expandButton);
|
||||||
|
|
||||||
const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings);
|
settingData.games[game].gameItems.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0)));
|
||||||
|
settingData.games[game].gameLocations.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0)));
|
||||||
|
|
||||||
|
const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings,
|
||||||
|
settingData.games[game].gameItems, settingData.games[game].gameLocations);
|
||||||
gameDiv.appendChild(weightedSettingsDiv);
|
gameDiv.appendChild(weightedSettingsDiv);
|
||||||
|
|
||||||
const itemsDiv = buildItemsDiv(game, settingData.games[game].gameItems);
|
const itemPoolDiv = buildItemsDiv(game, settingData.games[game].gameItems);
|
||||||
gameDiv.appendChild(itemsDiv);
|
gameDiv.appendChild(itemPoolDiv);
|
||||||
|
|
||||||
const hintsDiv = buildHintsDiv(game, settingData.games[game].gameItems, settingData.games[game].gameLocations);
|
const hintsDiv = buildHintsDiv(game, settingData.games[game].gameItems, settingData.games[game].gameLocations);
|
||||||
gameDiv.appendChild(hintsDiv);
|
gameDiv.appendChild(hintsDiv);
|
||||||
|
|
||||||
|
const locationsDiv = buildLocationsDiv(game, settingData.games[game].gameLocations);
|
||||||
|
gameDiv.appendChild(locationsDiv);
|
||||||
|
|
||||||
gamesWrapper.appendChild(gameDiv);
|
gamesWrapper.appendChild(gameDiv);
|
||||||
|
|
||||||
collapseButton.addEventListener('click', () => {
|
collapseButton.addEventListener('click', () => {
|
||||||
collapseButton.classList.add('invisible');
|
collapseButton.classList.add('invisible');
|
||||||
weightedSettingsDiv.classList.add('invisible');
|
weightedSettingsDiv.classList.add('invisible');
|
||||||
itemsDiv.classList.add('invisible');
|
itemPoolDiv.classList.add('invisible');
|
||||||
hintsDiv.classList.add('invisible');
|
hintsDiv.classList.add('invisible');
|
||||||
expandButton.classList.remove('invisible');
|
expandButton.classList.remove('invisible');
|
||||||
});
|
});
|
||||||
@@ -157,7 +166,7 @@ const buildUI = (settingData) => {
|
|||||||
expandButton.addEventListener('click', () => {
|
expandButton.addEventListener('click', () => {
|
||||||
collapseButton.classList.remove('invisible');
|
collapseButton.classList.remove('invisible');
|
||||||
weightedSettingsDiv.classList.remove('invisible');
|
weightedSettingsDiv.classList.remove('invisible');
|
||||||
itemsDiv.classList.remove('invisible');
|
itemPoolDiv.classList.remove('invisible');
|
||||||
hintsDiv.classList.remove('invisible');
|
hintsDiv.classList.remove('invisible');
|
||||||
expandButton.classList.add('invisible');
|
expandButton.classList.add('invisible');
|
||||||
});
|
});
|
||||||
@@ -225,7 +234,7 @@ const buildGameChoice = (games) => {
|
|||||||
gameChoiceDiv.appendChild(table);
|
gameChoiceDiv.appendChild(table);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildWeightedSettingsDiv = (game, settings) => {
|
const buildWeightedSettingsDiv = (game, settings, gameItems, gameLocations) => {
|
||||||
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
|
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
|
||||||
const settingsWrapper = document.createElement('div');
|
const settingsWrapper = document.createElement('div');
|
||||||
settingsWrapper.classList.add('settings-wrapper');
|
settingsWrapper.classList.add('settings-wrapper');
|
||||||
@@ -267,7 +276,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
|||||||
range.setAttribute('data-type', setting.type);
|
range.setAttribute('data-type', setting.type);
|
||||||
range.setAttribute('min', 0);
|
range.setAttribute('min', 0);
|
||||||
range.setAttribute('max', 50);
|
range.setAttribute('max', 50);
|
||||||
range.addEventListener('change', updateGameSetting);
|
range.addEventListener('change', updateRangeSetting);
|
||||||
range.value = currentSettings[game][settingName][option.value];
|
range.value = currentSettings[game][settingName][option.value];
|
||||||
tdMiddle.appendChild(range);
|
tdMiddle.appendChild(range);
|
||||||
tr.appendChild(tdMiddle);
|
tr.appendChild(tdMiddle);
|
||||||
@@ -293,33 +302,33 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
|||||||
if (((setting.max - setting.min) + 1) < 11) {
|
if (((setting.max - setting.min) + 1) < 11) {
|
||||||
for (let i=setting.min; i <= setting.max; ++i) {
|
for (let i=setting.min; i <= setting.max; ++i) {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
const tdLeft = document.createElement('td');
|
const tdLeft = document.createElement('td');
|
||||||
tdLeft.classList.add('td-left');
|
tdLeft.classList.add('td-left');
|
||||||
tdLeft.innerText = i;
|
tdLeft.innerText = i;
|
||||||
tr.appendChild(tdLeft);
|
tr.appendChild(tdLeft);
|
||||||
|
|
||||||
const tdMiddle = document.createElement('td');
|
const tdMiddle = document.createElement('td');
|
||||||
tdMiddle.classList.add('td-middle');
|
tdMiddle.classList.add('td-middle');
|
||||||
const range = document.createElement('input');
|
const range = document.createElement('input');
|
||||||
range.setAttribute('type', 'range');
|
range.setAttribute('type', 'range');
|
||||||
range.setAttribute('id', `${game}-${settingName}-${i}-range`);
|
range.setAttribute('id', `${game}-${settingName}-${i}-range`);
|
||||||
range.setAttribute('data-game', game);
|
range.setAttribute('data-game', game);
|
||||||
range.setAttribute('data-setting', settingName);
|
range.setAttribute('data-setting', settingName);
|
||||||
range.setAttribute('data-option', i);
|
range.setAttribute('data-option', i);
|
||||||
range.setAttribute('min', 0);
|
range.setAttribute('min', 0);
|
||||||
range.setAttribute('max', 50);
|
range.setAttribute('max', 50);
|
||||||
range.addEventListener('change', updateGameSetting);
|
range.addEventListener('change', updateRangeSetting);
|
||||||
range.value = currentSettings[game][settingName][i];
|
range.value = currentSettings[game][settingName][i] || 0;
|
||||||
tdMiddle.appendChild(range);
|
tdMiddle.appendChild(range);
|
||||||
tr.appendChild(tdMiddle);
|
tr.appendChild(tdMiddle);
|
||||||
|
|
||||||
const tdRight = document.createElement('td');
|
const tdRight = document.createElement('td');
|
||||||
tdRight.setAttribute('id', `${game}-${settingName}-${i}`)
|
tdRight.setAttribute('id', `${game}-${settingName}-${i}`)
|
||||||
tdRight.classList.add('td-right');
|
tdRight.classList.add('td-right');
|
||||||
tdRight.innerText = range.value;
|
tdRight.innerText = range.value;
|
||||||
tr.appendChild(tdRight);
|
tr.appendChild(tdRight);
|
||||||
|
|
||||||
rangeTbody.appendChild(tr);
|
rangeTbody.appendChild(tr);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const hintText = document.createElement('p');
|
const hintText = document.createElement('p');
|
||||||
@@ -376,7 +385,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
|||||||
range.setAttribute('data-option', option);
|
range.setAttribute('data-option', option);
|
||||||
range.setAttribute('min', 0);
|
range.setAttribute('min', 0);
|
||||||
range.setAttribute('max', 50);
|
range.setAttribute('max', 50);
|
||||||
range.addEventListener('change', updateGameSetting);
|
range.addEventListener('change', updateRangeSetting);
|
||||||
range.value = currentSettings[game][settingName][parseInt(option, 10)];
|
range.value = currentSettings[game][settingName][parseInt(option, 10)];
|
||||||
tdMiddle.appendChild(range);
|
tdMiddle.appendChild(range);
|
||||||
tr.appendChild(tdMiddle);
|
tr.appendChild(tdMiddle);
|
||||||
@@ -401,11 +410,17 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
|||||||
tr.appendChild(tdDelete);
|
tr.appendChild(tdDelete);
|
||||||
|
|
||||||
rangeTbody.appendChild(tr);
|
rangeTbody.appendChild(tr);
|
||||||
|
|
||||||
|
// Save new option to settings
|
||||||
|
range.dispatchEvent(new Event('change'));
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.keys(currentSettings[game][settingName]).forEach((option) => {
|
Object.keys(currentSettings[game][settingName]).forEach((option) => {
|
||||||
if (currentSettings[game][settingName][option] > 0) {
|
// These options are statically generated below, and should always appear even if they are deleted
|
||||||
const tr = document.createElement('tr');
|
// from localStorage
|
||||||
|
if (['random-low', 'random', 'random-high'].includes(option)) { return; }
|
||||||
|
|
||||||
|
const tr = document.createElement('tr');
|
||||||
const tdLeft = document.createElement('td');
|
const tdLeft = document.createElement('td');
|
||||||
tdLeft.classList.add('td-left');
|
tdLeft.classList.add('td-left');
|
||||||
tdLeft.innerText = option;
|
tdLeft.innerText = option;
|
||||||
@@ -421,7 +436,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
|||||||
range.setAttribute('data-option', option);
|
range.setAttribute('data-option', option);
|
||||||
range.setAttribute('min', 0);
|
range.setAttribute('min', 0);
|
||||||
range.setAttribute('max', 50);
|
range.setAttribute('max', 50);
|
||||||
range.addEventListener('change', updateGameSetting);
|
range.addEventListener('change', updateRangeSetting);
|
||||||
range.value = currentSettings[game][settingName][parseInt(option, 10)];
|
range.value = currentSettings[game][settingName][parseInt(option, 10)];
|
||||||
tdMiddle.appendChild(range);
|
tdMiddle.appendChild(range);
|
||||||
tr.appendChild(tdMiddle);
|
tr.appendChild(tdMiddle);
|
||||||
@@ -439,14 +454,15 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
|||||||
deleteButton.innerText = '❌';
|
deleteButton.innerText = '❌';
|
||||||
deleteButton.addEventListener('click', () => {
|
deleteButton.addEventListener('click', () => {
|
||||||
range.value = 0;
|
range.value = 0;
|
||||||
range.dispatchEvent(new Event('change'));
|
const changeEvent = new Event('change');
|
||||||
|
changeEvent.action = 'rangeDelete';
|
||||||
|
range.dispatchEvent(changeEvent);
|
||||||
rangeTbody.removeChild(tr);
|
rangeTbody.removeChild(tr);
|
||||||
});
|
});
|
||||||
tdDelete.appendChild(deleteButton);
|
tdDelete.appendChild(deleteButton);
|
||||||
tr.appendChild(tdDelete);
|
tr.appendChild(tdDelete);
|
||||||
|
|
||||||
rangeTbody.appendChild(tr);
|
rangeTbody.appendChild(tr);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +470,17 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
|||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
const tdLeft = document.createElement('td');
|
const tdLeft = document.createElement('td');
|
||||||
tdLeft.classList.add('td-left');
|
tdLeft.classList.add('td-left');
|
||||||
tdLeft.innerText = option;
|
switch(option){
|
||||||
|
case 'random':
|
||||||
|
tdLeft.innerText = 'Random';
|
||||||
|
break;
|
||||||
|
case 'random-low':
|
||||||
|
tdLeft.innerText = "Random (Low)";
|
||||||
|
break;
|
||||||
|
case 'random-high':
|
||||||
|
tdLeft.innerText = "Random (High)";
|
||||||
|
break;
|
||||||
|
}
|
||||||
tr.appendChild(tdLeft);
|
tr.appendChild(tdLeft);
|
||||||
|
|
||||||
const tdMiddle = document.createElement('td');
|
const tdMiddle = document.createElement('td');
|
||||||
@@ -467,7 +493,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
|||||||
range.setAttribute('data-option', option);
|
range.setAttribute('data-option', option);
|
||||||
range.setAttribute('min', 0);
|
range.setAttribute('min', 0);
|
||||||
range.setAttribute('max', 50);
|
range.setAttribute('max', 50);
|
||||||
range.addEventListener('change', updateGameSetting);
|
range.addEventListener('change', updateRangeSetting);
|
||||||
range.value = currentSettings[game][settingName][option];
|
range.value = currentSettings[game][settingName][option];
|
||||||
tdMiddle.appendChild(range);
|
tdMiddle.appendChild(range);
|
||||||
tr.appendChild(tdMiddle);
|
tr.appendChild(tdMiddle);
|
||||||
@@ -485,15 +511,108 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'items-list':
|
case 'items-list':
|
||||||
// TODO
|
const itemsList = document.createElement('div');
|
||||||
|
itemsList.classList.add('simple-list');
|
||||||
|
|
||||||
|
Object.values(gameItems).forEach((item) => {
|
||||||
|
const itemRow = document.createElement('div');
|
||||||
|
itemRow.classList.add('list-row');
|
||||||
|
|
||||||
|
const itemLabel = document.createElement('label');
|
||||||
|
itemLabel.setAttribute('for', `${game}-${settingName}-${item}`)
|
||||||
|
|
||||||
|
const itemCheckbox = document.createElement('input');
|
||||||
|
itemCheckbox.setAttribute('id', `${game}-${settingName}-${item}`);
|
||||||
|
itemCheckbox.setAttribute('type', 'checkbox');
|
||||||
|
itemCheckbox.setAttribute('data-game', game);
|
||||||
|
itemCheckbox.setAttribute('data-setting', settingName);
|
||||||
|
itemCheckbox.setAttribute('data-option', item.toString());
|
||||||
|
itemCheckbox.addEventListener('change', updateListSetting);
|
||||||
|
if (currentSettings[game][settingName].includes(item)) {
|
||||||
|
itemCheckbox.setAttribute('checked', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemName = document.createElement('span');
|
||||||
|
itemName.innerText = item.toString();
|
||||||
|
|
||||||
|
itemLabel.appendChild(itemCheckbox);
|
||||||
|
itemLabel.appendChild(itemName);
|
||||||
|
|
||||||
|
itemRow.appendChild(itemLabel);
|
||||||
|
itemsList.appendChild((itemRow));
|
||||||
|
});
|
||||||
|
|
||||||
|
settingWrapper.appendChild(itemsList);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'locations-list':
|
case 'locations-list':
|
||||||
// TODO
|
const locationsList = document.createElement('div');
|
||||||
|
locationsList.classList.add('simple-list');
|
||||||
|
|
||||||
|
Object.values(gameLocations).forEach((location) => {
|
||||||
|
const locationRow = document.createElement('div');
|
||||||
|
locationRow.classList.add('list-row');
|
||||||
|
|
||||||
|
const locationLabel = document.createElement('label');
|
||||||
|
locationLabel.setAttribute('for', `${game}-${settingName}-${location}`)
|
||||||
|
|
||||||
|
const locationCheckbox = document.createElement('input');
|
||||||
|
locationCheckbox.setAttribute('id', `${game}-${settingName}-${location}`);
|
||||||
|
locationCheckbox.setAttribute('type', 'checkbox');
|
||||||
|
locationCheckbox.setAttribute('data-game', game);
|
||||||
|
locationCheckbox.setAttribute('data-setting', settingName);
|
||||||
|
locationCheckbox.setAttribute('data-option', location.toString());
|
||||||
|
locationCheckbox.addEventListener('change', updateListSetting);
|
||||||
|
if (currentSettings[game][settingName].includes(location)) {
|
||||||
|
locationCheckbox.setAttribute('checked', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationName = document.createElement('span');
|
||||||
|
locationName.innerText = location.toString();
|
||||||
|
|
||||||
|
locationLabel.appendChild(locationCheckbox);
|
||||||
|
locationLabel.appendChild(locationName);
|
||||||
|
|
||||||
|
locationRow.appendChild(locationLabel);
|
||||||
|
locationsList.appendChild((locationRow));
|
||||||
|
});
|
||||||
|
|
||||||
|
settingWrapper.appendChild(locationsList);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'custom-list':
|
case 'custom-list':
|
||||||
// TODO
|
const customList = document.createElement('div');
|
||||||
|
customList.classList.add('simple-list');
|
||||||
|
|
||||||
|
Object.values(settings[settingName].options).forEach((listItem) => {
|
||||||
|
const customListRow = document.createElement('div');
|
||||||
|
customListRow.classList.add('list-row');
|
||||||
|
|
||||||
|
const customItemLabel = document.createElement('label');
|
||||||
|
customItemLabel.setAttribute('for', `${game}-${settingName}-${listItem}`)
|
||||||
|
|
||||||
|
const customItemCheckbox = document.createElement('input');
|
||||||
|
customItemCheckbox.setAttribute('id', `${game}-${settingName}-${listItem}`);
|
||||||
|
customItemCheckbox.setAttribute('type', 'checkbox');
|
||||||
|
customItemCheckbox.setAttribute('data-game', game);
|
||||||
|
customItemCheckbox.setAttribute('data-setting', settingName);
|
||||||
|
customItemCheckbox.setAttribute('data-option', listItem.toString());
|
||||||
|
customItemCheckbox.addEventListener('change', updateListSetting);
|
||||||
|
if (currentSettings[game][settingName].includes(listItem)) {
|
||||||
|
customItemCheckbox.setAttribute('checked', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
const customItemName = document.createElement('span');
|
||||||
|
customItemName.innerText = listItem.toString();
|
||||||
|
|
||||||
|
customItemLabel.appendChild(customItemCheckbox);
|
||||||
|
customItemLabel.appendChild(customItemName);
|
||||||
|
|
||||||
|
customListRow.appendChild(customItemLabel);
|
||||||
|
customList.appendChild((customListRow));
|
||||||
|
});
|
||||||
|
|
||||||
|
settingWrapper.appendChild(customList);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -719,21 +838,22 @@ const buildHintsDiv = (game, items, locations) => {
|
|||||||
const hintsDescription = document.createElement('p');
|
const hintsDescription = document.createElement('p');
|
||||||
hintsDescription.classList.add('setting-description');
|
hintsDescription.classList.add('setting-description');
|
||||||
hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' +
|
hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' +
|
||||||
' items are, or what those locations contain. Excluded locations will not contain progression items.';
|
' items are, or what those locations contain.';
|
||||||
hintsDiv.appendChild(hintsDescription);
|
hintsDiv.appendChild(hintsDescription);
|
||||||
|
|
||||||
const itemHintsContainer = document.createElement('div');
|
const itemHintsContainer = document.createElement('div');
|
||||||
itemHintsContainer.classList.add('hints-container');
|
itemHintsContainer.classList.add('hints-container');
|
||||||
|
|
||||||
|
// Item Hints
|
||||||
const itemHintsWrapper = document.createElement('div');
|
const itemHintsWrapper = document.createElement('div');
|
||||||
itemHintsWrapper.classList.add('hints-wrapper');
|
itemHintsWrapper.classList.add('hints-wrapper');
|
||||||
itemHintsWrapper.innerText = 'Starting Item Hints';
|
itemHintsWrapper.innerText = 'Starting Item Hints';
|
||||||
|
|
||||||
const itemHintsDiv = document.createElement('div');
|
const itemHintsDiv = document.createElement('div');
|
||||||
itemHintsDiv.classList.add('item-container');
|
itemHintsDiv.classList.add('simple-list');
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const itemDiv = document.createElement('div');
|
const itemRow = document.createElement('div');
|
||||||
itemDiv.classList.add('hint-div');
|
itemRow.classList.add('list-row');
|
||||||
|
|
||||||
const itemLabel = document.createElement('label');
|
const itemLabel = document.createElement('label');
|
||||||
itemLabel.setAttribute('for', `${game}-start_hints-${item}`);
|
itemLabel.setAttribute('for', `${game}-start_hints-${item}`);
|
||||||
@@ -747,29 +867,30 @@ const buildHintsDiv = (game, items, locations) => {
|
|||||||
if (currentSettings[game].start_hints.includes(item)) {
|
if (currentSettings[game].start_hints.includes(item)) {
|
||||||
itemCheckbox.setAttribute('checked', 'true');
|
itemCheckbox.setAttribute('checked', 'true');
|
||||||
}
|
}
|
||||||
itemCheckbox.addEventListener('change', hintChangeHandler);
|
itemCheckbox.addEventListener('change', updateListSetting);
|
||||||
itemLabel.appendChild(itemCheckbox);
|
itemLabel.appendChild(itemCheckbox);
|
||||||
|
|
||||||
const itemName = document.createElement('span');
|
const itemName = document.createElement('span');
|
||||||
itemName.innerText = item;
|
itemName.innerText = item;
|
||||||
itemLabel.appendChild(itemName);
|
itemLabel.appendChild(itemName);
|
||||||
|
|
||||||
itemDiv.appendChild(itemLabel);
|
itemRow.appendChild(itemLabel);
|
||||||
itemHintsDiv.appendChild(itemDiv);
|
itemHintsDiv.appendChild(itemRow);
|
||||||
});
|
});
|
||||||
|
|
||||||
itemHintsWrapper.appendChild(itemHintsDiv);
|
itemHintsWrapper.appendChild(itemHintsDiv);
|
||||||
itemHintsContainer.appendChild(itemHintsWrapper);
|
itemHintsContainer.appendChild(itemHintsWrapper);
|
||||||
|
|
||||||
|
// Starting Location Hints
|
||||||
const locationHintsWrapper = document.createElement('div');
|
const locationHintsWrapper = document.createElement('div');
|
||||||
locationHintsWrapper.classList.add('hints-wrapper');
|
locationHintsWrapper.classList.add('hints-wrapper');
|
||||||
locationHintsWrapper.innerText = 'Starting Location Hints';
|
locationHintsWrapper.innerText = 'Starting Location Hints';
|
||||||
|
|
||||||
const locationHintsDiv = document.createElement('div');
|
const locationHintsDiv = document.createElement('div');
|
||||||
locationHintsDiv.classList.add('item-container');
|
locationHintsDiv.classList.add('simple-list');
|
||||||
locations.forEach((location) => {
|
locations.forEach((location) => {
|
||||||
const locationDiv = document.createElement('div');
|
const locationRow = document.createElement('div');
|
||||||
locationDiv.classList.add('hint-div');
|
locationRow.classList.add('list-row');
|
||||||
|
|
||||||
const locationLabel = document.createElement('label');
|
const locationLabel = document.createElement('label');
|
||||||
locationLabel.setAttribute('for', `${game}-start_location_hints-${location}`);
|
locationLabel.setAttribute('for', `${game}-start_location_hints-${location}`);
|
||||||
@@ -783,29 +904,89 @@ const buildHintsDiv = (game, items, locations) => {
|
|||||||
if (currentSettings[game].start_location_hints.includes(location)) {
|
if (currentSettings[game].start_location_hints.includes(location)) {
|
||||||
locationCheckbox.setAttribute('checked', '1');
|
locationCheckbox.setAttribute('checked', '1');
|
||||||
}
|
}
|
||||||
locationCheckbox.addEventListener('change', hintChangeHandler);
|
locationCheckbox.addEventListener('change', updateListSetting);
|
||||||
locationLabel.appendChild(locationCheckbox);
|
locationLabel.appendChild(locationCheckbox);
|
||||||
|
|
||||||
const locationName = document.createElement('span');
|
const locationName = document.createElement('span');
|
||||||
locationName.innerText = location;
|
locationName.innerText = location;
|
||||||
locationLabel.appendChild(locationName);
|
locationLabel.appendChild(locationName);
|
||||||
|
|
||||||
locationDiv.appendChild(locationLabel);
|
locationRow.appendChild(locationLabel);
|
||||||
locationHintsDiv.appendChild(locationDiv);
|
locationHintsDiv.appendChild(locationRow);
|
||||||
});
|
});
|
||||||
|
|
||||||
locationHintsWrapper.appendChild(locationHintsDiv);
|
locationHintsWrapper.appendChild(locationHintsDiv);
|
||||||
itemHintsContainer.appendChild(locationHintsWrapper);
|
itemHintsContainer.appendChild(locationHintsWrapper);
|
||||||
|
|
||||||
|
hintsDiv.appendChild(itemHintsContainer);
|
||||||
|
return hintsDiv;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildLocationsDiv = (game, locations) => {
|
||||||
|
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
|
||||||
|
locations.sort(); // Sort alphabetical, in-place
|
||||||
|
|
||||||
|
const locationsDiv = document.createElement('div');
|
||||||
|
locationsDiv.classList.add('locations-div');
|
||||||
|
const locationsHeader = document.createElement('h3');
|
||||||
|
locationsHeader.innerText = 'Priority & Exclusion Locations';
|
||||||
|
locationsDiv.appendChild(locationsHeader);
|
||||||
|
const locationsDescription = document.createElement('p');
|
||||||
|
locationsDescription.classList.add('setting-description');
|
||||||
|
locationsDescription.innerText = 'Priority locations guarantee a progression item will be placed there while ' +
|
||||||
|
'excluded locations will not contain progression or useful items.';
|
||||||
|
locationsDiv.appendChild(locationsDescription);
|
||||||
|
|
||||||
|
const locationsContainer = document.createElement('div');
|
||||||
|
locationsContainer.classList.add('locations-container');
|
||||||
|
|
||||||
|
// Priority Locations
|
||||||
|
const priorityLocationsWrapper = document.createElement('div');
|
||||||
|
priorityLocationsWrapper.classList.add('locations-wrapper');
|
||||||
|
priorityLocationsWrapper.innerText = 'Priority Locations';
|
||||||
|
|
||||||
|
const priorityLocationsDiv = document.createElement('div');
|
||||||
|
priorityLocationsDiv.classList.add('simple-list');
|
||||||
|
locations.forEach((location) => {
|
||||||
|
const locationRow = document.createElement('div');
|
||||||
|
locationRow.classList.add('list-row');
|
||||||
|
|
||||||
|
const locationLabel = document.createElement('label');
|
||||||
|
locationLabel.setAttribute('for', `${game}-priority_locations-${location}`);
|
||||||
|
|
||||||
|
const locationCheckbox = document.createElement('input');
|
||||||
|
locationCheckbox.setAttribute('type', 'checkbox');
|
||||||
|
locationCheckbox.setAttribute('id', `${game}-priority_locations-${location}`);
|
||||||
|
locationCheckbox.setAttribute('data-game', game);
|
||||||
|
locationCheckbox.setAttribute('data-setting', 'priority_locations');
|
||||||
|
locationCheckbox.setAttribute('data-option', location);
|
||||||
|
if (currentSettings[game].priority_locations.includes(location)) {
|
||||||
|
locationCheckbox.setAttribute('checked', '1');
|
||||||
|
}
|
||||||
|
locationCheckbox.addEventListener('change', updateListSetting);
|
||||||
|
locationLabel.appendChild(locationCheckbox);
|
||||||
|
|
||||||
|
const locationName = document.createElement('span');
|
||||||
|
locationName.innerText = location;
|
||||||
|
locationLabel.appendChild(locationName);
|
||||||
|
|
||||||
|
locationRow.appendChild(locationLabel);
|
||||||
|
priorityLocationsDiv.appendChild(locationRow);
|
||||||
|
});
|
||||||
|
|
||||||
|
priorityLocationsWrapper.appendChild(priorityLocationsDiv);
|
||||||
|
locationsContainer.appendChild(priorityLocationsWrapper);
|
||||||
|
|
||||||
|
// Exclude Locations
|
||||||
const excludeLocationsWrapper = document.createElement('div');
|
const excludeLocationsWrapper = document.createElement('div');
|
||||||
excludeLocationsWrapper.classList.add('hints-wrapper');
|
excludeLocationsWrapper.classList.add('locations-wrapper');
|
||||||
excludeLocationsWrapper.innerText = 'Exclude Locations';
|
excludeLocationsWrapper.innerText = 'Exclude Locations';
|
||||||
|
|
||||||
const excludeLocationsDiv = document.createElement('div');
|
const excludeLocationsDiv = document.createElement('div');
|
||||||
excludeLocationsDiv.classList.add('item-container');
|
excludeLocationsDiv.classList.add('simple-list');
|
||||||
locations.forEach((location) => {
|
locations.forEach((location) => {
|
||||||
const locationDiv = document.createElement('div');
|
const locationRow = document.createElement('div');
|
||||||
locationDiv.classList.add('hint-div');
|
locationRow.classList.add('list-row');
|
||||||
|
|
||||||
const locationLabel = document.createElement('label');
|
const locationLabel = document.createElement('label');
|
||||||
locationLabel.setAttribute('for', `${game}-exclude_locations-${location}`);
|
locationLabel.setAttribute('for', `${game}-exclude_locations-${location}`);
|
||||||
@@ -819,40 +1000,22 @@ const buildHintsDiv = (game, items, locations) => {
|
|||||||
if (currentSettings[game].exclude_locations.includes(location)) {
|
if (currentSettings[game].exclude_locations.includes(location)) {
|
||||||
locationCheckbox.setAttribute('checked', '1');
|
locationCheckbox.setAttribute('checked', '1');
|
||||||
}
|
}
|
||||||
locationCheckbox.addEventListener('change', hintChangeHandler);
|
locationCheckbox.addEventListener('change', updateListSetting);
|
||||||
locationLabel.appendChild(locationCheckbox);
|
locationLabel.appendChild(locationCheckbox);
|
||||||
|
|
||||||
const locationName = document.createElement('span');
|
const locationName = document.createElement('span');
|
||||||
locationName.innerText = location;
|
locationName.innerText = location;
|
||||||
locationLabel.appendChild(locationName);
|
locationLabel.appendChild(locationName);
|
||||||
|
|
||||||
locationDiv.appendChild(locationLabel);
|
locationRow.appendChild(locationLabel);
|
||||||
excludeLocationsDiv.appendChild(locationDiv);
|
excludeLocationsDiv.appendChild(locationRow);
|
||||||
});
|
});
|
||||||
|
|
||||||
excludeLocationsWrapper.appendChild(excludeLocationsDiv);
|
excludeLocationsWrapper.appendChild(excludeLocationsDiv);
|
||||||
itemHintsContainer.appendChild(excludeLocationsWrapper);
|
locationsContainer.appendChild(excludeLocationsWrapper);
|
||||||
|
|
||||||
hintsDiv.appendChild(itemHintsContainer);
|
locationsDiv.appendChild(locationsContainer);
|
||||||
return hintsDiv;
|
return locationsDiv;
|
||||||
};
|
|
||||||
|
|
||||||
const hintChangeHandler = (evt) => {
|
|
||||||
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
|
|
||||||
const game = evt.target.getAttribute('data-game');
|
|
||||||
const setting = evt.target.getAttribute('data-setting');
|
|
||||||
const option = evt.target.getAttribute('data-option');
|
|
||||||
|
|
||||||
if (evt.target.checked) {
|
|
||||||
if (!currentSettings[game][setting].includes(option)) {
|
|
||||||
currentSettings[game][setting].push(option);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (currentSettings[game][setting].includes(option)) {
|
|
||||||
currentSettings[game][setting].splice(currentSettings[game][setting].indexOf(option), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
localStorage.setItem('weighted-settings', JSON.stringify(currentSettings));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateVisibleGames = () => {
|
const updateVisibleGames = () => {
|
||||||
@@ -898,14 +1061,37 @@ const updateBaseSetting = (event) => {
|
|||||||
localStorage.setItem('weighted-settings', JSON.stringify(settings));
|
localStorage.setItem('weighted-settings', JSON.stringify(settings));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateGameSetting = (evt) => {
|
const updateRangeSetting = (evt) => {
|
||||||
const options = JSON.parse(localStorage.getItem('weighted-settings'));
|
const options = JSON.parse(localStorage.getItem('weighted-settings'));
|
||||||
const game = evt.target.getAttribute('data-game');
|
const game = evt.target.getAttribute('data-game');
|
||||||
const setting = evt.target.getAttribute('data-setting');
|
const setting = evt.target.getAttribute('data-setting');
|
||||||
const option = evt.target.getAttribute('data-option');
|
const option = evt.target.getAttribute('data-option');
|
||||||
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
|
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
|
||||||
options[game][setting][option] = isNaN(evt.target.value) ?
|
if (evt.action && evt.action === 'rangeDelete') {
|
||||||
evt.target.value : parseInt(evt.target.value, 10);
|
delete options[game][setting][option];
|
||||||
|
} else {
|
||||||
|
options[game][setting][option] = parseInt(evt.target.value, 10);
|
||||||
|
}
|
||||||
|
localStorage.setItem('weighted-settings', JSON.stringify(options));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateListSetting = (evt) => {
|
||||||
|
const options = JSON.parse(localStorage.getItem('weighted-settings'));
|
||||||
|
const game = evt.target.getAttribute('data-game');
|
||||||
|
const setting = evt.target.getAttribute('data-setting');
|
||||||
|
const option = evt.target.getAttribute('data-option');
|
||||||
|
|
||||||
|
if (evt.target.checked) {
|
||||||
|
// If the option is to be enabled and it is already enabled, do nothing
|
||||||
|
if (options[game][setting].includes(option)) { return; }
|
||||||
|
|
||||||
|
options[game][setting].push(option);
|
||||||
|
} else {
|
||||||
|
// If the option is to be disabled and it is already disabled, do nothing
|
||||||
|
if (!options[game][setting].includes(option)) { return; }
|
||||||
|
|
||||||
|
options[game][setting].splice(options[game][setting].indexOf(option), 1);
|
||||||
|
}
|
||||||
localStorage.setItem('weighted-settings', JSON.stringify(options));
|
localStorage.setItem('weighted-settings', JSON.stringify(options));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1013,6 +1199,7 @@ const generateGame = (raceMode = false) => {
|
|||||||
weights: { player: JSON.stringify(settings) },
|
weights: { player: JSON.stringify(settings) },
|
||||||
presetData: { player: JSON.stringify(settings) },
|
presetData: { player: JSON.stringify(settings) },
|
||||||
playerCount: 1,
|
playerCount: 1,
|
||||||
|
spoiler: 3,
|
||||||
race: raceMode ? '1' : '0',
|
race: raceMode ? '1' : '0',
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
window.location.href = response.data.url;
|
window.location.href = response.data.url;
|
||||||
|
|||||||
BIN
WebHostLib/static/static/button-images/hamburger-menu-icon.png
Normal file
BIN
WebHostLib/static/static/button-images/hamburger-menu-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
WebHostLib/static/static/button-images/popover.png
Normal file
BIN
WebHostLib/static/static/button-images/popover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
30
WebHostLib/static/styles/checksfinderTracker.css
Normal file
30
WebHostLib/static/styles/checksfinderTracker.css
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#player-tracker-wrapper{
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table{
|
||||||
|
padding: 8px 10px 2px 6px;
|
||||||
|
background-color: #42b149;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table tr.column-headers td {
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0 5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table td{
|
||||||
|
padding: 0 0.5rem 0.5rem;
|
||||||
|
font-family: LexendDeca-Light, monospace;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table td img{
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -56,7 +56,3 @@
|
|||||||
#file-input{
|
#file-input{
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.interactive{
|
|
||||||
color: #ffef00;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -105,3 +105,10 @@ h5, h6{
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
background-color: #ffff00;
|
background-color: #ffff00;
|
||||||
}
|
}
|
||||||
|
.user-message a{
|
||||||
|
color: #ff7700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactive{
|
||||||
|
color: #ffef00;
|
||||||
|
}
|
||||||
@@ -55,4 +55,6 @@
|
|||||||
border: 1px solid #2a6c2f;
|
border: 1px solid #2a6c2f;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 400px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,33 @@
|
|||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
color: #dfedc6;
|
color: #dfedc6;
|
||||||
}
|
}
|
||||||
|
@media all and (max-width: 900px) {
|
||||||
|
#island-footer{
|
||||||
|
font-size: 17px;
|
||||||
|
font-size: 2vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media all and (max-width: 768px) {
|
||||||
|
#island-footer{
|
||||||
|
font-size: 15px;
|
||||||
|
font-size: 2vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media all and (max-width: 650px) {
|
||||||
|
#island-footer{
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 2vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media all and (max-width: 580px) {
|
||||||
|
#island-footer{
|
||||||
|
font-size: 11px;
|
||||||
|
font-size: 2vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media all and (max-width: 512px) {
|
||||||
|
#island-footer{
|
||||||
|
font-size: 9px;
|
||||||
|
font-size: 2vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ html{
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
height: 140px;
|
height: 140px;
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#landing-header h4{
|
#landing-header h4{
|
||||||
@@ -223,7 +222,7 @@ html{
|
|||||||
}
|
}
|
||||||
|
|
||||||
#landing{
|
#landing{
|
||||||
width: 700px;
|
max-width: 700px;
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
border-top-left-radius: 4px;
|
border-top-left-radius: 4px;
|
||||||
border-top-right-radius: 4px;
|
border-top-right-radius: 4px;
|
||||||
padding: 3px 3px 10px;
|
padding: 3px 3px 10px;
|
||||||
width: 448px;
|
width: 480px;
|
||||||
background-color: rgb(60, 114, 157);
|
background-color: rgb(60, 114, 157);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#location-table{
|
#location-table{
|
||||||
width: 448px;
|
width: 480px;
|
||||||
border-left: 2px solid #000000;
|
border-left: 2px solid #000000;
|
||||||
border-right: 2px solid #000000;
|
border-right: 2px solid #000000;
|
||||||
border-bottom: 2px solid #000000;
|
border-bottom: 2px solid #000000;
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#location-table td:first-child {
|
#location-table td:first-child {
|
||||||
width: 272px;
|
width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-category td:first-child {
|
.location-category td:first-child {
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ html{
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#player-settings table select:disabled{
|
||||||
|
background-color: lightgray;
|
||||||
|
}
|
||||||
|
|
||||||
#player-settings table .range-container{
|
#player-settings table .range-container{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -138,12 +142,27 @@ html{
|
|||||||
#player-settings table .special-range-wrapper{
|
#player-settings table .special-range-wrapper{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#player-settings table .special-range-wrapper input[type=range]{
|
#player-settings table .special-range-wrapper input[type=range]{
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#player-settings table .randomize-button {
|
||||||
|
max-height: 24px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin: 0 0 0 0.25rem;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player-settings table .randomize-button.active {
|
||||||
|
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
|
||||||
|
}
|
||||||
|
|
||||||
#player-settings table label{
|
#player-settings table label{
|
||||||
display: block;
|
display: block;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
|||||||
110
WebHostLib/static/styles/sc2wolTracker.css
Normal file
110
WebHostLib/static/styles/sc2wolTracker.css
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#player-tracker-wrapper{
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table{
|
||||||
|
border-top: 2px solid #000000;
|
||||||
|
border-left: 2px solid #000000;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
padding: 3px 3px 10px;
|
||||||
|
width: 500px;
|
||||||
|
background-color: #525494;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table td{
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table td.title{
|
||||||
|
padding-top: 10px;
|
||||||
|
height: 20px;
|
||||||
|
font-family: "JuraBook", monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table img{
|
||||||
|
height: 100%;
|
||||||
|
max-width: 40px;
|
||||||
|
max-height: 40px;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
filter: grayscale(100%) contrast(75%) brightness(20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table img.acquired{
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.counted-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.item-count {
|
||||||
|
text-align: left;
|
||||||
|
color: black;
|
||||||
|
font-family: "JuraBook", monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table{
|
||||||
|
width: 500px;
|
||||||
|
border-left: 2px solid #000000;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
background-color: #525494;
|
||||||
|
padding: 10px 3px 3px;
|
||||||
|
font-family: "JuraBook", monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table th{
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: left;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td{
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.counter {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.toggle-arrow {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table tr#Total-header {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table img{
|
||||||
|
height: 100%;
|
||||||
|
max-width: 30px;
|
||||||
|
max-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table tbody.locations {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location-table td.location-name {
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
html{
|
html{
|
||||||
padding-top: 110px;
|
padding-top: 110px;
|
||||||
|
scroll-padding-top: 100px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
#base-header{
|
#base-header{
|
||||||
@@ -28,6 +30,8 @@ html{
|
|||||||
}
|
}
|
||||||
|
|
||||||
#base-header-right{
|
#base-header-right{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +44,7 @@ html{
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#base-header a{
|
#base-header a, #base-header-mobile-menu a, #base-header-popover-text{
|
||||||
color: #2f6b83;
|
color: #2f6b83;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -49,3 +53,126 @@ html{
|
|||||||
font-family: LondrinaSolid-Light, sans-serif;
|
font-family: LondrinaSolid-Light, sans-serif;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#base-header-right-mobile{
|
||||||
|
display: none;
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base-header-mobile-menu{
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #ffffff;
|
||||||
|
text-align: center;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 10000;
|
||||||
|
width: 100vw;
|
||||||
|
border-bottom-left-radius: 20px;
|
||||||
|
border-bottom-right-radius: 20px;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
top: 7rem;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base-header-mobile-menu a{
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
font-size: 4rem;
|
||||||
|
line-height: 5rem;
|
||||||
|
color: #699ca8;
|
||||||
|
border-top: 1px solid #d3d3d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base-header-mobile-menu :first-child, #base-header-popover-menu :first-child{
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base-header-right-mobile img{
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base-header-popover-menu{
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
position: absolute;
|
||||||
|
background-color: #fff;
|
||||||
|
margin-left: -108px;
|
||||||
|
margin-top: 2.25rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 2px solid #d0ebe6;
|
||||||
|
border-bottom: 2px solid #d0ebe6;
|
||||||
|
border-right: 1px solid #d0ebe6;
|
||||||
|
filter: drop-shadow(-6px 6px 2px #2e3e83);
|
||||||
|
}
|
||||||
|
|
||||||
|
#base-header-popover-menu a{
|
||||||
|
color: #699ca8;
|
||||||
|
border-top: 1px solid #d3d3d3;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 3rem;
|
||||||
|
margin-right: 2px;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base-header-popover-icon {
|
||||||
|
width: 14px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media all and (max-width: 960px), only screen and (max-device-width: 768px) {
|
||||||
|
#base-header-right{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base-header-right-mobile{
|
||||||
|
display: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media all and (max-width: 960px){
|
||||||
|
#base-header-right-mobile{
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base-header-right-mobile img{
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base-header-mobile-menu{
|
||||||
|
top: 3.3rem;
|
||||||
|
width: unset;
|
||||||
|
border-left: 2px solid #d0ebe6;
|
||||||
|
border-bottom: 2px solid #d0ebe6;
|
||||||
|
filter: drop-shadow(-6px 6px 2px #2e3e83);
|
||||||
|
border-top-left-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base-header-mobile-menu a{
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 3rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-device-width: 768px){
|
||||||
|
html{
|
||||||
|
padding-top: 260px;
|
||||||
|
scroll-padding-top: 230px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base-header{
|
||||||
|
height: 200px;
|
||||||
|
background-size: auto 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#base-header #site-title img{
|
||||||
|
height: calc(38px * 2);
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pre{
|
|||||||
|
|
||||||
pre code{
|
pre code{
|
||||||
border: none;
|
border: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code{
|
||||||
|
|||||||
@@ -9,19 +9,54 @@
|
|||||||
border-top-left-radius: 4px;
|
border-top-left-radius: 4px;
|
||||||
border-top-right-radius: 4px;
|
border-top-right-radius: 4px;
|
||||||
padding: 3px 3px 10px;
|
padding: 3px 3px 10px;
|
||||||
width: 384px;
|
width: 374px;
|
||||||
background-color: #8d60a7;
|
background-color: #8d60a7;
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table td{
|
display: grid;
|
||||||
width: 40px;
|
grid-template-rows: repeat(5, 48px);
|
||||||
height: 40px;
|
}
|
||||||
text-align: center;
|
|
||||||
vertical-align: middle;
|
#inventory-table img{
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.table-row{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.C1{
|
||||||
|
grid-column: 1;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
#inventory-table div.C2{
|
||||||
|
grid-column: 2;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
#inventory-table div.C3{
|
||||||
|
grid-column: 3;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
#inventory-table div.C4{
|
||||||
|
grid-column: 4;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
#inventory-table div.C5{
|
||||||
|
grid-column: 5;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
#inventory-table img{
|
#inventory-table img{
|
||||||
height: 100%;
|
|
||||||
max-width: 40px;
|
max-width: 40px;
|
||||||
max-height: 40px;
|
max-height: 40px;
|
||||||
filter: grayscale(100%) contrast(75%) brightness(30%);
|
filter: grayscale(100%) contrast(75%) brightness(30%);
|
||||||
@@ -31,11 +66,70 @@
|
|||||||
filter: none;
|
filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#inventory-table div.counted-item {
|
#inventory-table img.acquired.purple{ /*00FFFF*/
|
||||||
|
filter: hue-rotate(270deg) saturate(6) brightness(0.8);
|
||||||
|
}
|
||||||
|
#inventory-table img.acquired.cyan{ /*FF00FF*/
|
||||||
|
filter: hue-rotate(138deg) saturate(10) brightness(0.8);
|
||||||
|
}
|
||||||
|
#inventory-table img.acquired.green{ /*32CD32*/
|
||||||
|
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.image-stack{
|
||||||
|
display: grid;
|
||||||
|
position: relative;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.image-stack div.stack-back{
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.image-stack div.stack-front{
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px 20px;
|
||||||
|
grid-template-rows: 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.image-stack div.stack-top-left{
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.image-stack div.stack-top-right{
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.image-stack div.stack-bottum-left{
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.image-stack div.stack-bottum-right{
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.image-stack div.stack-front img{
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table div.counted-item{
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
#inventory-table div.item-count {
|
#inventory-table div.item-count{
|
||||||
position: absolute;
|
position: absolute;
|
||||||
color: white;
|
color: white;
|
||||||
font-family: "Minecraftia", monospace;
|
font-family: "Minecraftia", monospace;
|
||||||
@@ -69,16 +163,16 @@
|
|||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#location-table td.counter {
|
#location-table td.counter{
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#location-table td.toggle-arrow {
|
#location-table td.toggle-arrow{
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
#location-table tr#Total-header {
|
#location-table tr#Total-header{
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,14 +182,14 @@
|
|||||||
max-height: 30px;
|
max-height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#location-table tbody.locations {
|
#location-table tbody.locations{
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#location-table td.location-name {
|
#location-table td.location-name{
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hide {
|
.hide{
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
/* Base styles for the element that has a tooltip */
|
/* Base styles for the element that has a tooltip */
|
||||||
[data-tooltip], .tooltip {
|
[data-tooltip], .tooltip {
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base styles for the entire tooltip */
|
/* Base styles for the entire tooltip */
|
||||||
@@ -55,14 +54,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
|||||||
|
|
||||||
/** Content styles */
|
/** Content styles */
|
||||||
.tooltip:after, [data-tooltip]:after {
|
.tooltip:after, [data-tooltip]:after {
|
||||||
|
width: 260px;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
width: 160px;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: #000;
|
background-color: #000;
|
||||||
background-color: hsla(0, 0%, 20%, 0.9);
|
background-color: hsla(0, 0%, 20%, 0.9);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
content: attr(data-tooltip);
|
content: attr(data-tooltip);
|
||||||
|
white-space: pre-wrap;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,33 @@ img.alttp-sprite {
|
|||||||
background-color: #d3c97d;
|
background-color: #d3c97d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#tracker-navigation {
|
||||||
|
display: inline-flex;
|
||||||
|
background-color: #b0a77d;
|
||||||
|
margin: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracker-navigation-button {
|
||||||
|
display: block;
|
||||||
|
margin: 4px;
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #000;
|
||||||
|
font-weight: lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracker-navigation-button:hover {
|
||||||
|
background-color: #e2eabb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracker-navigation-button.selected {
|
||||||
|
background-color: rgb(220, 226, 189);
|
||||||
|
}
|
||||||
|
|
||||||
@media all and (max-width: 1700px) {
|
@media all and (max-width: 1700px) {
|
||||||
table.dataTable thead th.upper-row{
|
table.dataTable thead th.upper-row{
|
||||||
position: -webkit-sticky;
|
position: -webkit-sticky;
|
||||||
|
|||||||
@@ -157,41 +157,29 @@ html{
|
|||||||
background-color: rgba(0, 0, 0, 0.1);
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#weighted-settings .hints-div{
|
#weighted-settings .hints-div, #weighted-settings .locations-div{
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#weighted-settings .hints-div h3{
|
#weighted-settings .hints-div h3, #weighted-settings .locations-div h3{
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#weighted-settings .hints-div .hints-container{
|
#weighted-settings .hints-container, #weighted-settings .locations-container{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .hints-wrapper, #weighted-settings .locations-wrapper{
|
||||||
|
width: calc(50% - 0.5rem);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
#weighted-settings .hints-div .hints-wrapper{
|
#weighted-settings .hints-wrapper .simple-list, #weighted-settings .locations-wrapper .simple-list{
|
||||||
width: 32.5%;
|
margin-top: 0.25rem;
|
||||||
}
|
height: 300px;
|
||||||
|
font-weight: normal;
|
||||||
#weighted-settings .hints-div .hints-wrapper .hint-div{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#weighted-settings .hints-div .hints-wrapper .hint-div:hover{
|
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#weighted-settings .hints-div .hints-wrapper .hint-div label{
|
|
||||||
flex-grow: 1;
|
|
||||||
padding: 0.125rem 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#weighted-settings #weighted-settings-button-row{
|
#weighted-settings #weighted-settings-button-row{
|
||||||
@@ -280,6 +268,30 @@ html{
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#weighted-settings .simple-list{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .simple-list .list-row label{
|
||||||
|
display: block;
|
||||||
|
width: calc(100% - 0.5rem);
|
||||||
|
padding: 0.0625rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .simple-list .list-row label:hover{
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#weighted-settings .simple-list .list-row label input[type=checkbox]{
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
#weighted-settings .invisible{
|
#weighted-settings .invisible{
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
|
import typing
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from colorsys import hsv_to_rgb
|
from colorsys import hsv_to_rgb
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from math import tau
|
from math import tau
|
||||||
import typing
|
|
||||||
|
|
||||||
|
from bokeh.colors import RGB
|
||||||
from bokeh.embed import components
|
from bokeh.embed import components
|
||||||
from bokeh.models import HoverTool
|
from bokeh.models import HoverTool
|
||||||
from bokeh.plotting import figure, ColumnDataSource
|
from bokeh.plotting import figure, ColumnDataSource
|
||||||
from bokeh.resources import INLINE
|
from bokeh.resources import INLINE
|
||||||
from bokeh.colors import RGB
|
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
from pony.orm import select
|
from pony.orm import select
|
||||||
|
|
||||||
@@ -18,10 +18,11 @@ from .models import Room
|
|||||||
PLOT_WIDTH = 600
|
PLOT_WIDTH = 600
|
||||||
|
|
||||||
|
|
||||||
def get_db_data(known_games: str) -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]:
|
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
|
||||||
|
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
|
||||||
games_played = defaultdict(Counter)
|
games_played = defaultdict(Counter)
|
||||||
total_games = Counter()
|
total_games = Counter()
|
||||||
cutoff = date.today()-timedelta(days=30)
|
cutoff = date.today() - timedelta(days=30)
|
||||||
room: Room
|
room: Room
|
||||||
for room in select(room for room in Room if room.creation_time >= cutoff):
|
for room in select(room for room in Room if room.creation_time >= cutoff):
|
||||||
for slot in room.seed.slots:
|
for slot in room.seed.slots:
|
||||||
@@ -93,7 +94,7 @@ def stats():
|
|||||||
occurences, legend_label=game, line_width=2, color=game_to_color[game])
|
occurences, legend_label=game, line_width=2, color=game_to_color[game])
|
||||||
|
|
||||||
total = sum(total_games.values())
|
total = sum(total_games.values())
|
||||||
pie = figure(plot_height=350, title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
|
pie = figure(title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
|
||||||
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
|
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
|
||||||
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
|
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
|
||||||
pie.axis.visible = False
|
pie.axis.visible = False
|
||||||
@@ -121,7 +122,8 @@ def stats():
|
|||||||
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
|
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
|
||||||
source=ColumnDataSource(data=data), legend_field="games")
|
source=ColumnDataSource(data=data), legend_field="games")
|
||||||
|
|
||||||
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in total_games
|
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in
|
||||||
|
sorted(total_games, key=lambda game: total_games[game])
|
||||||
if total_games[game] > 1]
|
if total_games[game] > 1]
|
||||||
|
|
||||||
script, charts = components((plot, pie, *per_game_charts))
|
script, charts = components((plot, pie, *per_game_charts))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() }}
|
|
||||||
<title>Mystery Check Result</title>
|
<title>Mystery Check Result</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" />
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<div id="check-result" class="grass-island">
|
<div id="check-result" class="grass-island">
|
||||||
<h1>Verification Results</h1>
|
<h1>Verification Results</h1>
|
||||||
<p>The results of your requested file check are below.</p>
|
<p>The results of your requested file check are below.</p>
|
||||||
<table class="table autodatatable">
|
<table id="results-table" class="table autodatatable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>File</th>
|
<th>File</th>
|
||||||
|
|||||||
35
WebHostLib/templates/checksfinderTracker.html
Normal file
35
WebHostLib/templates/checksfinderTracker.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/checksfinderTracker.css') }}" />
|
||||||
|
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/checksfinderTracker.js') }}"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||||
|
<table id="inventory-table">
|
||||||
|
<tr class="column-headers">
|
||||||
|
<td colspan="2">Checks Available:</td>
|
||||||
|
<td colspan="2">Map Bombs:</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img alt="Checks Available" src="{{ icons['Checks Available'] }}" /></td>
|
||||||
|
<td>{{ checks_available }}</td>
|
||||||
|
<td><img alt="Bombs Remaining" src="{{ icons['Map Bombs'] }}" /></td>
|
||||||
|
<td>{{ bombs_display }}/20</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="column-headers">
|
||||||
|
<td colspan="2">Map Width:</td>
|
||||||
|
<td colspan="2">Map Height:</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img alt="Map Width" src="{{ icons['Map Width'] }}" /></td>
|
||||||
|
<td>{{ width_display }}/10</td>
|
||||||
|
<td><img alt="Map Height" src="{{ icons['Map Height'] }}" /></td>
|
||||||
|
<td>{{ height_display }}/10</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{{ super() }}
|
|
||||||
<title>Generate Game</title>
|
<title>Generate Game</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" />
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script>
|
||||||
@@ -41,21 +40,20 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="forfeit_mode">Forfeit Permission:</label>
|
<label for="release_mode">Release Permission:
|
||||||
<span
|
<span class="interactive" data-tooltip="Permissions on when players are able to release all remaining items from their world.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="A forfeit releases all remaining items from the locations
|
</span>
|
||||||
in your world.">(?)
|
</label>
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select name="forfeit_mode" id="forfeit_mode">
|
<select name="release_mode" id="release_mode">
|
||||||
<option value="auto">Automatic on goal completion</option>
|
<option value="auto">Automatic on goal completion</option>
|
||||||
<option value="goal">Allow !forfeit after goal completion</option>
|
<option value="goal">Allow !release after goal completion</option>
|
||||||
<option value="auto-enabled">
|
<option value="auto-enabled">
|
||||||
Automatic on goal completion and manual !forfeit
|
Automatic on goal completion and manual !release
|
||||||
</option>
|
</option>
|
||||||
<option value="enabled">Manual !forfeit</option>
|
<option value="enabled">Manual !release</option>
|
||||||
<option value="disabled">Disabled</option>
|
<option value="disabled">Disabled</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
@@ -63,12 +61,11 @@
|
|||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="collect_mode">Collect Permission:</label>
|
<label for="collect_mode">Collect Permission:
|
||||||
<span
|
<span class="interactive" data-tooltip="Permissions on when players are able to collect all their remaining items from across the multiworld.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="A collect releases all of your remaining items to you
|
</span>
|
||||||
from across the multiworld.">(?)
|
</label>
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select name="collect_mode" id="collect_mode">
|
<select name="collect_mode" id="collect_mode">
|
||||||
@@ -85,12 +82,11 @@
|
|||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="remaining_mode">Remaining Permission:</label>
|
<label for="remaining_mode">Remaining Permission:
|
||||||
<span
|
<span class="interactive" data-tooltip="Remaining lists all items still in your world by name only.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="Remaining lists all items still in your world by name only."
|
</span>
|
||||||
>(?)
|
</label>
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select name="remaining_mode" id="remaining_mode">
|
<select name="remaining_mode" id="remaining_mode">
|
||||||
@@ -106,11 +102,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="item_cheat">Item Cheat:</label>
|
<label for="item_cheat">Item Cheat:
|
||||||
<span
|
<span class="interactive" data-tooltip="Allows players to use the !getitem command.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="Allows players to use the !getitem command.">(?)
|
</span>
|
||||||
</span>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select name="item_cheat" id="item_cheat">
|
<select name="item_cheat" id="item_cheat">
|
||||||
@@ -123,6 +119,28 @@
|
|||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<label for="spoiler">Spoiler Log:
|
||||||
|
<span class="interactive" data-tooltip="Generates a text listing all randomized elements.
|
||||||
|
Warning: playthrough can take a significant amount of time for larger multiworlds.">
|
||||||
|
(?)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select name="spoiler" id="spoiler">
|
||||||
|
{% if race -%}
|
||||||
|
<option value="0">Disabled in Race mode</option>
|
||||||
|
{%- else -%}
|
||||||
|
<option value="3">Enabled with playthrough and traversal</option>
|
||||||
|
<option value="2">Enabled with playthrough</option>
|
||||||
|
<option value="1">Enabled</option>
|
||||||
|
<option value="0">Disabled</option>
|
||||||
|
{%- endif -%}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,12 +149,11 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="hint_cost"> Hint Cost:</label>
|
<label for="hint_cost"> Hint Cost:
|
||||||
<span
|
<span class="interactive" data-tooltip="After gathering this many checks, players can !hint <itemname> to get the location of that hint item.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="After gathering this many checks, players can !hint <itemname>
|
</span>
|
||||||
to get the location of that hint item.">(?)
|
</label>
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select name="hint_cost" id="hint_cost">
|
<select name="hint_cost" id="hint_cost">
|
||||||
@@ -150,11 +167,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="server_password">Server Password:</label>
|
<label for="server_password">Server Password:
|
||||||
<span
|
<span class="interactive" data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command.">(?)
|
</span>
|
||||||
</span>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input id="server_password" name="server_password">
|
<input id="server_password" name="server_password">
|
||||||
@@ -162,23 +179,22 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label for="plando_options">Plando Options:</label>
|
Plando Options:
|
||||||
<span
|
<span class="interactive" data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information.">
|
||||||
class="interactive"
|
(?)
|
||||||
data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information.">(?)
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="checkbox" name="plando_bosses" value="bosses" checked>
|
<input type="checkbox" id="plando_bosses" name="plando_bosses" value="bosses" checked>
|
||||||
<label for="plando_bosses">Bosses</label><br>
|
<label for="plando_bosses">Bosses</label><br>
|
||||||
|
|
||||||
<input type="checkbox" name="plando_items" value="items" checked>
|
<input type="checkbox" id="plando_items" name="plando_items" value="items" checked>
|
||||||
<label for="plando_items">Items</label><br>
|
<label for="plando_items">Items</label><br>
|
||||||
|
|
||||||
<input type="checkbox" name="plando_connections" value="connections" checked>
|
<input type="checkbox" id="plando_connections" name="plando_connections" value="connections" checked>
|
||||||
<label for="plando_connections">Connections</label><br>
|
<label for="plando_connections">Connections</label><br>
|
||||||
|
|
||||||
<input type="checkbox" name="plando_texts" value="texts" checked>
|
<input type="checkbox" id="plando_texts" name="plando_texts" value="texts" checked>
|
||||||
<label for="plando_texts">Text</label>
|
<label for="plando_texts">Text</label>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -4,18 +4,18 @@
|
|||||||
<title>{{ player_name }}'s Tracker</title>
|
<title>{{ player_name }}'s Tracker</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% include 'header/dirtHeader.html' %}
|
{% include 'header/dirtHeader.html' %}
|
||||||
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}">
|
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}" data-second="{{ saving_second }}">
|
||||||
<div id="tracker-header-bar">
|
<div id="tracker-header-bar">
|
||||||
<input placeholder="Search" id="search"/>
|
<input placeholder="Search" id="search"/>
|
||||||
<span class="info">This tracker will automatically update itself periodically.</span>
|
<span class="info">This tracker will automatically update itself periodically.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table class="table non-unique-item-table">
|
<table id="received-table" class="table non-unique-item-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Item</th>
|
<th>Item</th>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table class="table non-unique-item-table">
|
<table id="locations-table" class="table non-unique-item-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Location</th>
|
<th>Location</th>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user