Compare commits
1170 Commits
0.2.5
...
autoworld_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4691ef8b64 | ||
|
|
cd218c15b3 | ||
|
|
ca2c0e6ce2 | ||
|
|
2a28a6de28 | ||
|
|
573a1a8402 | ||
|
|
060ee926e7 | ||
|
|
df55455fc0 | ||
|
|
4d7bd929bc | ||
|
|
030e41363a | ||
|
|
4bc0e84a7f | ||
|
|
070a92e76c | ||
|
|
39563cc347 | ||
|
|
54cce4c392 | ||
|
|
426a81a065 | ||
|
|
04e6a8eae8 | ||
|
|
0cfdc973f6 | ||
|
|
f3ca0a21c9 | ||
|
|
5fef41eb97 | ||
|
|
4068ba2f15 | ||
|
|
b1599c557f | ||
|
|
7fdf38b2ad | ||
|
|
2e76085cf1 | ||
|
|
c61f467218 | ||
|
|
942d689093 | ||
|
|
5e1aa52373 | ||
|
|
a95e51deda | ||
|
|
738319462d | ||
|
|
e3deb822ad | ||
|
|
d57314a407 | ||
|
|
5a8e6e61f5 | ||
|
|
17e90ce12c | ||
|
|
016157a0eb | ||
|
|
5b64c5f934 | ||
|
|
414166f6a2 | ||
|
|
e6109394ad | ||
|
|
8ca25fed63 | ||
|
|
227d59ecfb | ||
|
|
08c17c83d4 | ||
|
|
efb2ab4505 | ||
|
|
3a68ce3faa | ||
|
|
e78800d1bc | ||
|
|
96d7a3a64c | ||
|
|
30b70b2055 | ||
|
|
cd234fc04a | ||
|
|
d74c4c4c94 | ||
|
|
a4b61118cf | ||
|
|
9fa1f4e85f | ||
|
|
3a926849a0 | ||
|
|
798d823397 | ||
|
|
4ea582f14e | ||
|
|
21fb16291d | ||
|
|
805f33c39e | ||
|
|
0cf8206660 | ||
|
|
2c20b56478 | ||
|
|
1d2f7d8669 | ||
|
|
0733775f2c | ||
|
|
d6f3b27695 | ||
|
|
ce7e6bcf33 | ||
|
|
2c4658a7e0 | ||
|
|
79b8733b13 | ||
|
|
9cb9cbe47d | ||
|
|
7cad53c31a | ||
|
|
f3bdf0c5ed | ||
|
|
af7d0dbf37 | ||
|
|
0286edf20c | ||
|
|
05e36cab1c | ||
|
|
50425985c4 | ||
|
|
062d6eeace | ||
|
|
6c460bcbf7 | ||
|
|
b8659d28cc | ||
|
|
0b12d80008 | ||
|
|
5966aa5327 | ||
|
|
7c68e91d4a | ||
|
|
1d6ab13015 | ||
|
|
cb3d40624c | ||
|
|
0eb66957b1 | ||
|
|
53e2232f29 | ||
|
|
ecd2675ea8 | ||
|
|
fc2e555b4a | ||
|
|
df020bb389 | ||
|
|
7760034ff7 | ||
|
|
3e7794d5dc | ||
|
|
a3e8bb474a | ||
|
|
e4c95c940a | ||
|
|
daa1809a0f | ||
|
|
0a1261eb84 | ||
|
|
b62be6f7f4 | ||
|
|
ce2553a2b3 | ||
|
|
18c4b4b1fe | ||
|
|
a85ca9cc87 | ||
|
|
ad4846cedd | ||
|
|
b20be3ccec | ||
|
|
8af7908cd0 | ||
|
|
f078750b72 | ||
|
|
7cbeb8438b | ||
|
|
f7a0542898 | ||
|
|
cc61f16e57 | ||
|
|
9e3c2e2464 | ||
|
|
f528175d8a | ||
|
|
803d7105a1 | ||
|
|
a40f6058b5 | ||
|
|
0ff3c693d5 | ||
|
|
873a374a69 | ||
|
|
60584b7617 | ||
|
|
e24a85ca5c | ||
|
|
cc0540d3fb | ||
|
|
c360b9266c | ||
|
|
6148213e43 | ||
|
|
ff175008a1 | ||
|
|
cae1e683e2 | ||
|
|
fb1a9e9c5a | ||
|
|
555a0da46d | ||
|
|
0817305d5b | ||
|
|
995c978628 | ||
|
|
4de7ebd8b0 | ||
|
|
3cef39a387 | ||
|
|
ffff9ece55 | ||
|
|
dc2aa5f41e | ||
|
|
428344b6bc | ||
|
|
ea2175cb8a | ||
|
|
11873e059a | ||
|
|
6c1023a88c | ||
|
|
0be0732a2b | ||
|
|
c9aa283711 | ||
|
|
cf2204a861 | ||
|
|
dfdcad28e5 | ||
|
|
ab4324c901 | ||
|
|
1e251dcdc0 | ||
|
|
9c1f7bfea9 | ||
|
|
5393563700 | ||
|
|
28576f2b0d | ||
|
|
ba519fecd0 | ||
|
|
86fb450ecc | ||
|
|
920240cb6f | ||
|
|
53dd0d5a7d | ||
|
|
807f544b26 | ||
|
|
1d1693df62 | ||
|
|
51574959ec | ||
|
|
04f726aef2 | ||
|
|
8a4298e504 | ||
|
|
e7f8f40464 | ||
|
|
847582ff5f | ||
|
|
1a44f5cf1c | ||
|
|
032bc75070 | ||
|
|
fb47483212 | ||
|
|
d185df3972 | ||
|
|
941dcb60e5 | ||
|
|
25756831b7 | ||
|
|
9add1495d5 | ||
|
|
34dba007dc | ||
|
|
02d3eef565 | ||
|
|
c839a76fe7 | ||
|
|
29e1c3dcf4 | ||
|
|
f6616da5a9 | ||
|
|
8678e02d54 | ||
|
|
2f37bedc92 | ||
|
|
91fdfe3e17 | ||
|
|
a41b0051a6 | ||
|
|
b8abe9f980 | ||
|
|
dd3ae5ecbd | ||
|
|
e96602d31b | ||
|
|
81d953daa3 | ||
|
|
bd774a454e | ||
|
|
ca724c92ad | ||
|
|
11eebbbd32 | ||
|
|
608794cded | ||
|
|
816de5ff02 | ||
|
|
0b941e2268 | ||
|
|
57713cda50 | ||
|
|
f56cdd6ec3 | ||
|
|
773c517757 | ||
|
|
2509b7fa3f | ||
|
|
10652d23e0 | ||
|
|
f0bc3d33ac | ||
|
|
92d1ed60c6 | ||
|
|
fe2b431821 | ||
|
|
0cc83698f9 | ||
|
|
428f643b07 | ||
|
|
d4e2b75520 | ||
|
|
96cc7f79dc | ||
|
|
bdfbc7e14a | ||
|
|
94c6562f82 | ||
|
|
22fe31a141 | ||
|
|
72fa19ee1f | ||
|
|
d899e918b4 | ||
|
|
33d31c4f0f | ||
|
|
9c3c69702a | ||
|
|
dae1a3e0f9 | ||
|
|
1f1ef10cfe | ||
|
|
760af59308 | ||
|
|
3dd7e3e706 | ||
|
|
189b129dca | ||
|
|
092e8d14ad | ||
|
|
4cfc73b582 | ||
|
|
ff9c11d772 | ||
|
|
b83aec5c12 | ||
|
|
caf63dd737 | ||
|
|
395d35571c | ||
|
|
e0be79639c | ||
|
|
37b7f0d32d | ||
|
|
50677ee6a2 | ||
|
|
f8bc3359c7 | ||
|
|
6e537e17e6 | ||
|
|
e853fc208b | ||
|
|
1a36da33b4 | ||
|
|
56fc614588 | ||
|
|
47f1fcf382 | ||
|
|
51c6be047f | ||
|
|
2c46c48ba9 | ||
|
|
32820ba653 | ||
|
|
6173bc6e03 | ||
|
|
e71ea94fe5 | ||
|
|
e3f169b4c3 | ||
|
|
e4e74074f0 | ||
|
|
149630d532 | ||
|
|
2dcfbff751 | ||
|
|
ec45479c52 | ||
|
|
aee0df5359 | ||
|
|
2cdd03f786 | ||
|
|
ce42fda85f | ||
|
|
78a18dee4e | ||
|
|
b7d46004e2 | ||
|
|
c3fe341736 | ||
|
|
79bb43b77c | ||
|
|
bedc78d335 | ||
|
|
1b582e5b09 | ||
|
|
f278dd95c5 | ||
|
|
92f75f3e03 | ||
|
|
f5adc7bdc5 | ||
|
|
78d4da53a7 | ||
|
|
e206c065bf | ||
|
|
5273812039 | ||
|
|
7c3af68e59 | ||
|
|
449973687b | ||
|
|
f5638552cc | ||
|
|
78ee19de51 | ||
|
|
82444229be | ||
|
|
2cc03d003a | ||
|
|
0e4fa378dd | ||
|
|
ffc000ec91 | ||
|
|
32b8f9f9f3 | ||
|
|
4412434976 | ||
|
|
9bdbced51f | ||
|
|
bd574ef261 | ||
|
|
45719eb7e0 | ||
|
|
d81fd280fa | ||
|
|
6b57275859 | ||
|
|
63f012cce7 | ||
|
|
679cb3e197 | ||
|
|
38b5a90c07 | ||
|
|
203f17f0f6 | ||
|
|
65995cd586 | ||
|
|
64e2d55e92 | ||
|
|
ef66f64030 | ||
|
|
e641c3ca1b | ||
|
|
111c3186bd | ||
|
|
f0e9080108 | ||
|
|
fd8867c782 | ||
|
|
f81d2653e0 | ||
|
|
1288f15e45 | ||
|
|
cde2a6e754 | ||
|
|
81dd1e359b | ||
|
|
8dffd87bee | ||
|
|
67be80e59d | ||
|
|
ff1f5569e7 | ||
|
|
8b9b482972 | ||
|
|
d0ce44cd38 | ||
|
|
aae78a8a12 | ||
|
|
7a5e11e8d4 | ||
|
|
a9ab53cb8b | ||
|
|
5ed8c2e1c0 | ||
|
|
67128ece38 | ||
|
|
8aed24151f | ||
|
|
3e6c097348 | ||
|
|
8ce3fd5518 | ||
|
|
93a354cd81 | ||
|
|
774581b7ba | ||
|
|
95f90851ac | ||
|
|
1cd1bfea4d | ||
|
|
edd1fff4b7 | ||
|
|
4d79920fa6 | ||
|
|
7665935227 | ||
|
|
5139475068 | ||
|
|
adcee639a2 | ||
|
|
fde97fca5b | ||
|
|
e108b67ca5 | ||
|
|
17da06f763 | ||
|
|
2ff737175f | ||
|
|
b0b8268249 | ||
|
|
4e5c10ad66 | ||
|
|
350e1e6287 | ||
|
|
63c0d027e7 | ||
|
|
a014bb4ab7 | ||
|
|
0d10fec395 | ||
|
|
0cbee4ac3e | ||
|
|
70cab99caf | ||
|
|
c1e97bcbff | ||
|
|
e2eaafbf70 | ||
|
|
66d594e95b | ||
|
|
a9bf0008ba | ||
|
|
f2426ae603 | ||
|
|
462ddce72c | ||
|
|
d10bb3c6c1 | ||
|
|
61232ca756 | ||
|
|
8f325a4f2b | ||
|
|
d28738a918 | ||
|
|
1f3d048462 | ||
|
|
b161a5241f | ||
|
|
208a0c6b08 | ||
|
|
c3c1ce5827 | ||
|
|
889bc9d1b4 | ||
|
|
165a38dd58 | ||
|
|
88088dd054 | ||
|
|
c933fa7e34 | ||
|
|
f1123f2662 | ||
|
|
0f034ddcf7 | ||
|
|
7f3eda4623 | ||
|
|
2b0e7f05da | ||
|
|
e204deab02 | ||
|
|
56afd62175 | ||
|
|
44204ac9be | ||
|
|
6c3852a2a9 | ||
|
|
124ae198e4 | ||
|
|
030b767751 | ||
|
|
5ca724a454 | ||
|
|
af3b752093 | ||
|
|
c378933274 | ||
|
|
da392239a0 | ||
|
|
a6e1e14fee | ||
|
|
95378233fc | ||
|
|
85130f2bbd | ||
|
|
ab9f3767e2 | ||
|
|
bf142b32c9 | ||
|
|
05c06a57af | ||
|
|
0f7adaaf7b | ||
|
|
962e48c078 | ||
|
|
95ea0541e6 | ||
|
|
0ed3baabd4 | ||
|
|
2db55ac50b | ||
|
|
bea8d37a3c | ||
|
|
813015e007 | ||
|
|
c1d7abd06e | ||
|
|
655f287d42 | ||
|
|
802119502d | ||
|
|
2af510328e | ||
|
|
87f4a97f1e | ||
|
|
1bb99d391d | ||
|
|
1cad51b1af | ||
|
|
09d8c4b912 | ||
|
|
ed23a426ec | ||
|
|
c711264d1a | ||
|
|
3dfbbc5057 | ||
|
|
f298b8d6e7 | ||
|
|
53974d568b | ||
|
|
ec0389eefb | ||
|
|
0c54c47023 | ||
|
|
80db8a33af | ||
|
|
e6c6b00109 | ||
|
|
813ea6ef8b | ||
|
|
c09e089f9d | ||
|
|
cfff12d8d7 | ||
|
|
924f484be0 | ||
|
|
aeb78eaa10 | ||
|
|
6134578c60 | ||
|
|
b57ca33c31 | ||
|
|
4b18920819 | ||
|
|
700fe8b75e | ||
|
|
d5efc71344 | ||
|
|
6535836e5c | ||
|
|
89d1a80e01 | ||
|
|
ad445629bd | ||
|
|
37c5865c0e | ||
|
|
52726139b4 | ||
|
|
24105ac249 | ||
|
|
f18df4c1df | ||
|
|
04b6c31076 | ||
|
|
40c3ef35c7 | ||
|
|
28483a6c14 | ||
|
|
fa077defe0 | ||
|
|
47b4e2782b | ||
|
|
265ee7098a | ||
|
|
ed76c13961 | ||
|
|
40b7e78178 | ||
|
|
1900d9382a | ||
|
|
f12b73f487 | ||
|
|
49ae79e5ce | ||
|
|
4da6a0bb98 | ||
|
|
af0cfc5a38 | ||
|
|
1aa3e431c8 | ||
|
|
b533ffb9e8 | ||
|
|
bb46ee7fc1 | ||
|
|
acf7fda26a | ||
|
|
f7fc6fa7aa | ||
|
|
5e97463bdc | ||
|
|
ca9c3d05d6 | ||
|
|
51f65f4b9e | ||
|
|
1f01404ca4 | ||
|
|
bbb6ee89cf | ||
|
|
0aea1e780f | ||
|
|
722b3c5369 | ||
|
|
097ac189e4 | ||
|
|
7f3f886e41 | ||
|
|
3bd4ef3f3d | ||
|
|
6b9073acd7 | ||
|
|
e708bea819 | ||
|
|
b014ce082b | ||
|
|
30a4bcbbbe | ||
|
|
0afb7096de | ||
|
|
f909576813 | ||
|
|
9f684b3dc0 | ||
|
|
37a40499fa | ||
|
|
099c4fca3c | ||
|
|
106d630ad7 | ||
|
|
4c0c93b083 | ||
|
|
3cbbf905d1 | ||
|
|
414ebf2640 | ||
|
|
3297be7902 | ||
|
|
7b3ef012b9 | ||
|
|
af6a72c3c3 | ||
|
|
38b7bdfe60 | ||
|
|
4c266e6eff | ||
|
|
8a6c9ff4b8 | ||
|
|
fdd7ffb089 | ||
|
|
b8e467fbb8 | ||
|
|
411cd51a92 | ||
|
|
e9e15e854d | ||
|
|
4943d26160 | ||
|
|
060a04700d | ||
|
|
61e39f355d | ||
|
|
8ab0b410c3 | ||
|
|
d897aaade2 | ||
|
|
0191df88d7 | ||
|
|
bee1fd9b5a | ||
|
|
dd7d3a02a4 | ||
|
|
13edfa60be | ||
|
|
885c8d3fcc | ||
|
|
e6a4925f0c | ||
|
|
c96b6d7b95 | ||
|
|
8bc8b412a3 | ||
|
|
b4b9ff5d82 | ||
|
|
b21b5cceb8 | ||
|
|
813ee5ee3b | ||
|
|
be1158ad78 | ||
|
|
6d5ddf3cad | ||
|
|
809bda02d1 | ||
|
|
2d5ec6ce22 | ||
|
|
a95d0ce9ef | ||
|
|
267d9234e5 | ||
|
|
4686881566 | ||
|
|
101dab0ea4 | ||
|
|
c2d69cb05e | ||
|
|
58f66e0f42 | ||
|
|
0215e1fa28 | ||
|
|
1c0a93acad | ||
|
|
4fcde135e5 | ||
|
|
332dde154f | ||
|
|
8d51205e8f | ||
|
|
ff05e9d7d5 | ||
|
|
516a52c041 | ||
|
|
9daa64741b | ||
|
|
af11fa5150 | ||
|
|
156e9e0e43 | ||
|
|
ef46979bd8 | ||
|
|
b2aa251c47 | ||
|
|
e204a0fce6 | ||
|
|
bb386d3bd7 | ||
|
|
88a225764a | ||
|
|
99d2caa57d | ||
|
|
ade82e3d60 | ||
|
|
7c04e7e06f | ||
|
|
baf51e5959 | ||
|
|
8aad75ed23 | ||
|
|
1792b66b3a | ||
|
|
5e8ac74b2a | ||
|
|
2acc129381 | ||
|
|
0cbb3c2839 | ||
|
|
539d2e80f1 | ||
|
|
f9e28004a0 | ||
|
|
b7cfcc9272 | ||
|
|
4b6d46fd74 | ||
|
|
b45d8bf221 | ||
|
|
f7d107fc0c | ||
|
|
b14d694e1e | ||
|
|
8d2333006a | ||
|
|
e413619c26 | ||
|
|
03f66a922d | ||
|
|
b115bdafe7 | ||
|
|
0444fdc379 | ||
|
|
c617bba959 | ||
|
|
8da1cfeeb7 | ||
|
|
fcfc2c2e10 | ||
|
|
a753905ee4 | ||
|
|
2a7babce68 | ||
|
|
60d1a27079 | ||
|
|
4a2a184db1 | ||
|
|
45fb735320 | ||
|
|
3eb9e7050f | ||
|
|
26aed9351e | ||
|
|
b1ffbc49c9 | ||
|
|
6d6111de2a | ||
|
|
cc8ce32c61 | ||
|
|
4c94bb0ad5 | ||
|
|
af19180ff0 | ||
|
|
a175aa93e7 | ||
|
|
a78863fde1 | ||
|
|
0d6cbd9093 | ||
|
|
1aaf89ff2c | ||
|
|
295ea97544 | ||
|
|
33103b209d | ||
|
|
fab12dca0b | ||
|
|
c390801c4c | ||
|
|
e548abd332 | ||
|
|
0a5b24be2b | ||
|
|
7f41cafffc | ||
|
|
d66f981be6 | ||
|
|
b66a265726 | ||
|
|
c695f91198 | ||
|
|
11cbc0b40b | ||
|
|
87d91aeef3 | ||
|
|
6a6dfcbaff | ||
|
|
9553627136 | ||
|
|
a4a8894d22 | ||
|
|
bf217dcf85 | ||
|
|
484ee9f065 | ||
|
|
bba82ccd6c | ||
|
|
fb122df5f5 | ||
|
|
be8c3131d8 | ||
|
|
9341332379 | ||
|
|
83bcb441bf | ||
|
|
a074d16297 | ||
|
|
89ab4aff9c | ||
|
|
0ac67bfe76 | ||
|
|
0d61192c67 | ||
|
|
a1aa9c17ff | ||
|
|
d0faa36eef | ||
|
|
22c8153ba8 | ||
|
|
6602c580f4 | ||
|
|
431a9b7023 | ||
|
|
d426226bce | ||
|
|
09afdc2553 | ||
|
|
ca83905d9f | ||
|
|
086295adbb | ||
|
|
81cf1508e0 | ||
|
|
8484193151 | ||
|
|
d10fbf8263 | ||
|
|
f73b3d71bf | ||
|
|
d48d775a59 | ||
|
|
f716bfc58f | ||
|
|
97b388747a | ||
|
|
898fa203ad | ||
|
|
c02c6ee58c | ||
|
|
23b04b5069 | ||
|
|
0ed0d17f38 | ||
|
|
645ede869f | ||
|
|
f5e48c850d | ||
|
|
9bd035a19d | ||
|
|
2e428f906c | ||
|
|
b702ae482b | ||
|
|
b8ca41b45f | ||
|
|
adc16fdd3d | ||
|
|
b32d0efe6d | ||
|
|
c96acbfa23 | ||
|
|
ffe528467e | ||
|
|
b989698740 | ||
|
|
29e0975832 | ||
|
|
e1e2526322 | ||
|
|
f2e83c37e9 | ||
|
|
debda5d111 | ||
|
|
2c4e819010 | ||
|
|
b3700dabf2 | ||
|
|
fb2979d9ef | ||
|
|
a378d62dfd | ||
|
|
eb5ba72cfc | ||
|
|
c1e9d0ab4f | ||
|
|
181cc47079 | ||
|
|
04eef669f9 | ||
|
|
9167e5363d | ||
|
|
f1c5c9a148 | ||
|
|
69e5627cd7 | ||
|
|
ae3e6c29e3 | ||
|
|
f6da81ac70 | ||
|
|
dd6e212519 | ||
|
|
95bba50223 | ||
|
|
21f7c6c0ad | ||
|
|
d15c30f63b | ||
|
|
db5b7e5db9 | ||
|
|
7c808bb03b | ||
|
|
530b6cc360 | ||
|
|
95012c004f | ||
|
|
59918b9dbc | ||
|
|
b47cca4515 | ||
|
|
5f27019855 | ||
|
|
0b228834c2 | ||
|
|
57979b9287 | ||
|
|
4b85000960 | ||
|
|
d1f34d088b | ||
|
|
3bc9392e5b | ||
|
|
75165803a0 | ||
|
|
afc9c772be | ||
|
|
07450bb83d | ||
|
|
2ff7e83ad9 | ||
|
|
d817fdcfdb | ||
|
|
f3d966897f | ||
|
|
9acaf1c279 | ||
|
|
fd6a0b547f | ||
|
|
c02f355479 | ||
|
|
7d9203ef84 | ||
|
|
e849e4792d | ||
|
|
4565b3af8d | ||
|
|
e5b868e0e9 | ||
|
|
489450d3fa | ||
|
|
73afab67c8 | ||
|
|
c61f77029b | ||
|
|
79702aba65 | ||
|
|
1e366ff66f | ||
|
|
a0482cf27e | ||
|
|
288a623ab6 | ||
|
|
3b2037a2d4 | ||
|
|
ce536fa3ac | ||
|
|
41883e44e7 | ||
|
|
c3ff201b90 | ||
|
|
e6635cdd77 | ||
|
|
cfc9d79c79 | ||
|
|
fe2c355739 | ||
|
|
04c3429839 | ||
|
|
cabbe0aaf6 | ||
|
|
a7787d87f9 | ||
|
|
79b851189f | ||
|
|
9e972eafb2 | ||
|
|
53a995372f | ||
|
|
17351021b3 | ||
|
|
8ff2c1b6f3 | ||
|
|
45aea2c8ff | ||
|
|
9f5e40283a | ||
|
|
025309ec64 | ||
|
|
bd4850b2b5 | ||
|
|
472e114fb9 | ||
|
|
828bcb1266 | ||
|
|
9897f4eb4b | ||
|
|
e1ef820184 | ||
|
|
b3ad766680 | ||
|
|
74b19dc1f5 | ||
|
|
449bc93307 | ||
|
|
622af17705 | ||
|
|
a42f7f99fe | ||
|
|
3c6bd555b4 | ||
|
|
a4211d5f11 | ||
|
|
090c5bcf00 | ||
|
|
82850d7f66 | ||
|
|
86112351a6 | ||
|
|
ce789d1e3e | ||
|
|
73fb1b8074 | ||
|
|
8e15fe51b6 | ||
|
|
aa954b776d | ||
|
|
76f6eb1434 | ||
|
|
e38308bac3 | ||
|
|
e804f592de | ||
|
|
6e0a0c5c4a | ||
|
|
122590fc68 | ||
|
|
c806366469 | ||
|
|
0d3bd6e2e8 | ||
|
|
beac0b1acd | ||
|
|
1cc9c7a469 | ||
|
|
17db0805a7 | ||
|
|
2f53972c85 | ||
|
|
9ac780102e | ||
|
|
60b80083e0 | ||
|
|
8597b04c41 | ||
|
|
6a60c46a99 | ||
|
|
5c2163a1a7 | ||
|
|
a49bcd618d | ||
|
|
d76b41afe7 | ||
|
|
ab2b635a77 | ||
|
|
7072c7bd45 | ||
|
|
530c5500c3 | ||
|
|
8870b577d0 | ||
|
|
7d85ab471a | ||
|
|
3205cbf932 | ||
|
|
b9fb4de878 | ||
|
|
bcd7096e1d | ||
|
|
b206f2846a | ||
|
|
8a8bc6aa34 | ||
|
|
bce7c258c3 | ||
|
|
cea7278faf | ||
|
|
d7a9b98ce8 | ||
|
|
7dcde12e2e | ||
|
|
ba2a5c4744 | ||
|
|
39ac3c38bf | ||
|
|
61f751a1db | ||
|
|
5f2193f2e4 | ||
|
|
98b714f84a | ||
|
|
2a0198b618 | ||
|
|
cd9f8f3119 | ||
|
|
37b569eca6 | ||
|
|
d317111d20 | ||
|
|
3f1d216d28 | ||
|
|
0ca3d73ae9 | ||
|
|
1972d531b9 | ||
|
|
5006c79a00 | ||
|
|
8788ee1aa7 | ||
|
|
17ba73b0b8 | ||
|
|
0407df83b7 | ||
|
|
f140aadafe | ||
|
|
b41c6185e4 | ||
|
|
aa3d7f5e21 | ||
|
|
efadf6fdf4 | ||
|
|
12863e9b04 | ||
|
|
1843618c99 | ||
|
|
4e5071fd68 | ||
|
|
6e918edce1 | ||
|
|
80ff5a18b1 | ||
|
|
d112cc585f | ||
|
|
3fec33f56c | ||
|
|
68674deb00 | ||
|
|
a9e530721d | ||
|
|
03e9034a98 | ||
|
|
6970c5ce97 | ||
|
|
10b3803a7f | ||
|
|
a7e8c82633 | ||
|
|
6d4c4295b3 | ||
|
|
47edc356ad | ||
|
|
b551e3a2ad | ||
|
|
a9c32bc2e2 | ||
|
|
60c7be87f8 | ||
|
|
2bac78b4a4 | ||
|
|
c4769eeebb | ||
|
|
51341f6255 | ||
|
|
c7a32dc91b | ||
|
|
3623678c93 | ||
|
|
a5d516e179 | ||
|
|
2045905c9b | ||
|
|
26c027a075 | ||
|
|
b86ee20f3f | ||
|
|
50c75e9684 | ||
|
|
d87c3d5323 | ||
|
|
247f674749 | ||
|
|
74fe03414c | ||
|
|
65d213c494 | ||
|
|
05a51346f9 | ||
|
|
6c525e1fe6 | ||
|
|
5be00e28dd | ||
|
|
d81dbbd951 | ||
|
|
83dee9d667 | ||
|
|
7d79cff66f | ||
|
|
0a63bd0fc6 | ||
|
|
55d8c8c928 | ||
|
|
681f7041dc | ||
|
|
d5f15e6408 | ||
|
|
70d510dff8 | ||
|
|
2a5c128267 | ||
|
|
e5a1052089 | ||
|
|
8c64f6221e | ||
|
|
0869a2acc3 | ||
|
|
e7ea827f02 | ||
|
|
84b6ece31d | ||
|
|
1bcc5b6582 | ||
|
|
c8c025ac34 | ||
|
|
d82d70ac97 | ||
|
|
3e86fd4e57 | ||
|
|
964eda13cc | ||
|
|
c16815b16d | ||
|
|
74ee8ec459 | ||
|
|
22ea72c1b2 | ||
|
|
613dc4184a | ||
|
|
9a471aff1b | ||
|
|
e69e42cabc | ||
|
|
1281426075 | ||
|
|
8b1baafddf | ||
|
|
ee65d7e5fa | ||
|
|
df0ae205cd | ||
|
|
1cbd384569 | ||
|
|
e47527087e | ||
|
|
517a2db9d8 | ||
|
|
fbf993566d | ||
|
|
25bea47872 | ||
|
|
78f22e895e | ||
|
|
fa3925cd74 | ||
|
|
d9418d5ce1 | ||
|
|
103f9e0b85 | ||
|
|
a2fc3d5b71 | ||
|
|
c66d64b9d8 | ||
|
|
0dd67f40ba | ||
|
|
f5dc39ddf0 | ||
|
|
6b47776b11 | ||
|
|
2b73c7f9e4 | ||
|
|
4558ac66fa | ||
|
|
d0a98949f5 | ||
|
|
e13e7f286c | ||
|
|
0045e3f9f7 | ||
|
|
ff608b72a2 | ||
|
|
19c3c8056b | ||
|
|
d31c24bbf7 | ||
|
|
768f9497fd | ||
|
|
20be691f36 | ||
|
|
3dd3f045e6 | ||
|
|
6d3538a35b | ||
|
|
1a0bfecb5f | ||
|
|
5d3b4c8efd | ||
|
|
8adc0dd7eb | ||
|
|
2cb71c5352 | ||
|
|
b6068f4519 | ||
|
|
21a6b0143d | ||
|
|
28949853f7 | ||
|
|
65c83393bb | ||
|
|
960988ddcd | ||
|
|
fb99dca83e | ||
|
|
e786243738 | ||
|
|
cec0e2cbfb | ||
|
|
dadd7d4693 | ||
|
|
dc558f906c | ||
|
|
8184e99409 | ||
|
|
ac87629550 | ||
|
|
1c231b703a | ||
|
|
a66b11e6ec | ||
|
|
4f24c4ea78 | ||
|
|
a800b148a2 | ||
|
|
1710e15e49 | ||
|
|
a332d4935d | ||
|
|
9b855c7de0 | ||
|
|
e8be80ccd7 | ||
|
|
c661da57d8 | ||
|
|
4165f58414 | ||
|
|
7126b7bca0 | ||
|
|
a7f647e3ca | ||
|
|
e901a87afd | ||
|
|
9eb237b3af | ||
|
|
909ea9dc99 | ||
|
|
86013328d6 | ||
|
|
0c80cd017f | ||
|
|
2b8a0f8cd8 | ||
|
|
e1926c973e | ||
|
|
f515f680a4 | ||
|
|
effba9fdec | ||
|
|
388f064307 | ||
|
|
bb15485965 | ||
|
|
cb9db5dff1 | ||
|
|
3b644a0af1 | ||
|
|
8ce2ecfaac | ||
|
|
bdd9ca76ee | ||
|
|
44ae50083d | ||
|
|
e5d999c755 | ||
|
|
4e90ebc7d9 | ||
|
|
dbf0458575 | ||
|
|
e6e44b8747 | ||
|
|
2b702528fd | ||
|
|
23144ff204 | ||
|
|
764b6c78c5 | ||
|
|
051e19e9c1 | ||
|
|
ad99850192 | ||
|
|
c93eeb3607 | ||
|
|
551cf8442f | ||
|
|
90d506ee7c | ||
|
|
45bca78e75 | ||
|
|
11faca1940 | ||
|
|
47b179dec4 | ||
|
|
05efbe0af8 | ||
|
|
48a7587c5a | ||
|
|
ff82145633 | ||
|
|
dcc703f454 | ||
|
|
07f66fb15a | ||
|
|
c0fb7d9f9a | ||
|
|
2b6fc6dd3a | ||
|
|
e147495fb9 | ||
|
|
b2e65a19a2 | ||
|
|
44638ccc1a | ||
|
|
5f4b2cfa52 | ||
|
|
0bc2301530 | ||
|
|
d1eda38745 | ||
|
|
dc10421531 | ||
|
|
00f5975a3c | ||
|
|
b41f444013 | ||
|
|
89b4060a06 | ||
|
|
98ca001da6 | ||
|
|
b0b41711d4 | ||
|
|
3f691d6977 | ||
|
|
977159e572 | ||
|
|
9e15e754c2 | ||
|
|
c085ee47ed | ||
|
|
a5ca118bbf | ||
|
|
521122fd4f | ||
|
|
86933d8150 | ||
|
|
976f34c19f | ||
|
|
a56340663c | ||
|
|
e3900e9f99 | ||
|
|
e8b1362172 | ||
|
|
f6d857b5b5 | ||
|
|
aa9f43dea1 | ||
|
|
513ab62ce7 | ||
|
|
a020dea277 | ||
|
|
19dd447dcb | ||
|
|
eb1abd9222 | ||
|
|
9ab7c8d9e5 | ||
|
|
1e592b4681 | ||
|
|
40a08d0d84 | ||
|
|
517e72f442 | ||
|
|
ea51df432d | ||
|
|
c27bfc515e | ||
|
|
7fad0b0f51 | ||
|
|
76663f819b | ||
|
|
666760f0cf | ||
|
|
2d73f2f46e | ||
|
|
c8e54bbcd0 | ||
|
|
76a4dce66a | ||
|
|
c102d602b3 | ||
|
|
e711490f6c | ||
|
|
c801cdbb3b | ||
|
|
9d638671bb | ||
|
|
4a703481ba | ||
|
|
897cbb9826 | ||
|
|
bb710cc360 | ||
|
|
5eab07d8d6 | ||
|
|
894a30b9bd | ||
|
|
e8579771a5 | ||
|
|
09670a4475 | ||
|
|
ff783cf9a5 | ||
|
|
46d31c3ee3 | ||
|
|
3e8c821c02 | ||
|
|
50eaf712a9 | ||
|
|
f476747ade | ||
|
|
d8d881085f | ||
|
|
fd6e1b3046 | ||
|
|
d6697924cb | ||
|
|
3001926ae4 | ||
|
|
578451fcfa | ||
|
|
d57bdf6dc3 | ||
|
|
0309fac592 | ||
|
|
9ecd320c8c | ||
|
|
c326566bd2 | ||
|
|
4f10dbb896 | ||
|
|
cb6d377796 | ||
|
|
b5f58b0a03 | ||
|
|
9ee5fae476 | ||
|
|
81feb2fd5e | ||
|
|
75a76fb184 | ||
|
|
21f1ccbfb4 | ||
|
|
0f5a7cda6c | ||
|
|
acd7bce903 | ||
|
|
1afacd28a1 | ||
|
|
6e171d19f0 | ||
|
|
66921499ad | ||
|
|
249972c7fd | ||
|
|
dae0e233b8 | ||
|
|
8bb566a250 | ||
|
|
6a25bbeef0 | ||
|
|
6286ac4a3b | ||
|
|
447f99ea15 | ||
|
|
587d4dc8b6 | ||
|
|
b5613ffcf5 | ||
|
|
1fe82b1312 | ||
|
|
a4daa78c0b | ||
|
|
618bdfc917 | ||
|
|
8e68aa0ccd | ||
|
|
df3757657e | ||
|
|
0eea1a1d89 | ||
|
|
15dcdca6fc | ||
|
|
7a6aef03e7 | ||
|
|
c61f3b9110 | ||
|
|
42fecc7491 | ||
|
|
0acca6dd64 | ||
|
|
ec00d1b710 | ||
|
|
f093e90c23 | ||
|
|
3d1f6d9b82 | ||
|
|
9bdcbb9008 | ||
|
|
491e6c8730 | ||
|
|
d32d268d97 | ||
|
|
30c447b9f3 | ||
|
|
2def8f35ad | ||
|
|
f2055daf1a | ||
|
|
944571ea89 | ||
|
|
f7c601b863 | ||
|
|
7315da2ccb | ||
|
|
2f7f6a0b58 | ||
|
|
3f43051c35 | ||
|
|
535c35310d | ||
|
|
8fbe6a4511 | ||
|
|
07ff0f1026 | ||
|
|
a080288e3e | ||
|
|
71bd87f293 | ||
|
|
574e2abba8 | ||
|
|
cffa772801 | ||
|
|
66bd793306 | ||
|
|
0eb37883ca | ||
|
|
356384ab05 | ||
|
|
8c2c6877b6 | ||
|
|
d1d40d8a60 | ||
|
|
b026a0a372 | ||
|
|
73bcd0058a | ||
|
|
0cf396e5d6 | ||
|
|
1bc09d4292 | ||
|
|
97d0c51db1 | ||
|
|
ed1c11267c | ||
|
|
a3e1ac896f | ||
|
|
37d9eb2752 | ||
|
|
05e267a0bd | ||
|
|
d1f0a29a02 | ||
|
|
fb2e780c56 | ||
|
|
ba3257f850 | ||
|
|
215d5e9adf | ||
|
|
5392b32d5c | ||
|
|
4dd0a75914 | ||
|
|
a2212002ae | ||
|
|
91ccee3513 | ||
|
|
2a593d5d0a | ||
|
|
a93b3d79aa | ||
|
|
938ab32cda | ||
|
|
6f5ab05345 | ||
|
|
95f8647f09 | ||
|
|
06c8caa3cc | ||
|
|
d206a562df | ||
|
|
a0a290e481 | ||
|
|
266ff0c520 | ||
|
|
931bf7da16 | ||
|
|
fe4a26d034 | ||
|
|
dca70a99ad | ||
|
|
1a24a73ccd | ||
|
|
ae163319e0 | ||
|
|
65864e273b | ||
|
|
199b778d2b | ||
|
|
70e3c47120 | ||
|
|
eddc5d6524 | ||
|
|
fae3068c25 | ||
|
|
b9014b2a60 | ||
|
|
6b07b6407c | ||
|
|
a10b987f1c | ||
|
|
1f16310797 | ||
|
|
0fd59063d9 | ||
|
|
aab477b874 | ||
|
|
098d939653 | ||
|
|
7d830362a7 | ||
|
|
0db1660369 | ||
|
|
c471a70b35 | ||
|
|
6aef6f2c11 | ||
|
|
000f0bf2f1 | ||
|
|
0f1c08b43a | ||
|
|
76ffb5cd53 | ||
|
|
23d245d43c | ||
|
|
aabc86fc01 | ||
|
|
cebd7fb545 | ||
|
|
8337689640 | ||
|
|
0263130126 | ||
|
|
c472d740ec | ||
|
|
0fd244eee0 | ||
|
|
7dcb6f66da | ||
|
|
14956d27bd | ||
|
|
420be2c44f | ||
|
|
3bb3a902b3 | ||
|
|
2b138ac940 | ||
|
|
b6eeef1db6 | ||
|
|
469dda7d85 | ||
|
|
3c2933d587 | ||
|
|
3b128c8512 | ||
|
|
fb1be7b003 | ||
|
|
e0aa52ed27 | ||
|
|
64ac619b46 | ||
|
|
902472be32 | ||
|
|
cb024b00d9 | ||
|
|
75de616465 | ||
|
|
c12d8e2f46 | ||
|
|
d8087660e6 | ||
|
|
87a8e6e20c | ||
|
|
b599a7607d | ||
|
|
a6b22d1f41 | ||
|
|
8e59761b03 | ||
|
|
8599506497 | ||
|
|
e4ab10fe92 | ||
|
|
171c297d1b | ||
|
|
5eccb0ed49 | ||
|
|
f326de2686 | ||
|
|
2ca6b7f929 | ||
|
|
79afae17e7 | ||
|
|
cb4d9dc365 | ||
|
|
4bf8b98681 | ||
|
|
7f1371ec00 | ||
|
|
cb3db8ae16 | ||
|
|
cf2e37f92d | ||
|
|
92319b0e31 | ||
|
|
d4ff653937 | ||
|
|
7df12930ef | ||
|
|
9ba70951d5 | ||
|
|
2d25369d06 | ||
|
|
affcaf1c02 | ||
|
|
7e314c0d7a | ||
|
|
1266ca314c | ||
|
|
7394598aff | ||
|
|
b02a710bc5 | ||
|
|
ce6966a823 | ||
|
|
689183edc0 | ||
|
|
43113c7844 | ||
|
|
fb8879a919 | ||
|
|
136b9f9138 | ||
|
|
eea326561e | ||
|
|
e3781c68be | ||
|
|
d2927dc68f | ||
|
|
ca95d47127 | ||
|
|
a5a0c94a2c | ||
|
|
cfa49ee757 | ||
|
|
8921baecd0 | ||
|
|
8b78477c69 | ||
|
|
14633724f2 | ||
|
|
8d3ea9c50f | ||
|
|
32a58b1adb | ||
|
|
f01a31ce56 | ||
|
|
3f69c3a2ab | ||
|
|
e0f3d6d0d7 | ||
|
|
a8f148acac | ||
|
|
0c57af40dc | ||
|
|
0714be6b73 | ||
|
|
b5ce6f0bb0 | ||
|
|
67d59067eb | ||
|
|
f1984a103d | ||
|
|
41fd7a8a56 | ||
|
|
14ac139d03 | ||
|
|
97b1ae5ee9 | ||
|
|
15e0763ed5 | ||
|
|
3ce5d14210 | ||
|
|
2c884e2ca5 | ||
|
|
c204fb9b14 | ||
|
|
69721d2d04 | ||
|
|
73b14d3826 | ||
|
|
7ca6f24e6c | ||
|
|
2c3e3f0d43 | ||
|
|
3b68c6902c | ||
|
|
c5926fcf2b | ||
|
|
e6546eea85 | ||
|
|
892357cc2c | ||
|
|
7c6fb26eb7 | ||
|
|
491530ad60 | ||
|
|
6667c1f03d | ||
|
|
e985fc41ce | ||
|
|
508eb04e94 | ||
|
|
68e9368bb3 | ||
|
|
db152e6790 | ||
|
|
6bf2f5611a | ||
|
|
11a13967d5 | ||
|
|
05fe423ef1 | ||
|
|
6e0165986f | ||
|
|
f167e11905 | ||
|
|
727cae902a | ||
|
|
f38f9a47da | ||
|
|
7708d3d157 | ||
|
|
4c64c5ad05 | ||
|
|
534ce179ec | ||
|
|
1b73bacde1 | ||
|
|
a13ad32ec5 | ||
|
|
13a6c86077 | ||
|
|
5fc1b760f4 | ||
|
|
a6d78d9af7 | ||
|
|
48669e96d1 | ||
|
|
071161176e | ||
|
|
f046d76c59 | ||
|
|
53ab224fba | ||
|
|
5faf1f27de | ||
|
|
f38b970ea2 | ||
|
|
5dbccfcbbd | ||
|
|
de5249f99e | ||
|
|
420320f896 | ||
|
|
06ac2d1805 | ||
|
|
cdc0b7a649 | ||
|
|
6c7be51221 | ||
|
|
1159137c0d | ||
|
|
a98cb040b7 | ||
|
|
170213e6d4 | ||
|
|
129c6d2d1e | ||
|
|
ad75ee8c50 | ||
|
|
e94b99da65 | ||
|
|
4f47709d32 | ||
|
|
71ea8d7148 | ||
|
|
919223cd2f | ||
|
|
fd8cace362 | ||
|
|
18d937d83e |
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
@@ -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
@@ -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
@@ -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.
|
||||
110
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
# This workflow will build a release-like distribution when manually dispatched
|
||||
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/workflows/build.yaml'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build.yaml'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
ENEMIZER_VERSION: 7.1
|
||||
APPIMAGETOOL_VERSION: 13
|
||||
|
||||
jobs:
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-win-py38: # RCs will still be built and signed by hand
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.8'
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||
- name: Build
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools
|
||||
pip install -r requirements.txt
|
||||
python setup.py build_exe --yes
|
||||
$NAME="$(ls build)".Split('.',2)[1]
|
||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
||||
New-Item -Path dist -ItemType Directory -Force
|
||||
cd build
|
||||
Rename-Item exe.$NAME Archipelago
|
||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||
- name: Store 7z
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ env.ZIP_NAME }}
|
||||
path: dist/${{ env.ZIP_NAME }}
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
|
||||
build-ubuntu1804:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
# - copy code below to release.yml -
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
||||
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
|
||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||
- name: Build
|
||||
run: |
|
||||
# pygobject is an optional dependency for kivy that's not in requirements
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer
|
||||
pip install -r requirements.txt
|
||||
python setup.py build_exe --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - copy code above to release.yml -
|
||||
- name: Store AppImage
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ env.APPIMAGE_NAME }}
|
||||
path: dist/${{ env.APPIMAGE_NAME }}
|
||||
retention-days: 7
|
||||
- name: Store .tar.gz
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ env.TAR_NAME }}
|
||||
path: dist/${{ env.TAR_NAME }}
|
||||
retention-days: 7
|
||||
2
.github/workflows/codeql-analysis.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
8
.github/workflows/lint.yml
vendored
@@ -11,15 +11,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8 pytest
|
||||
python -m pip install --upgrade pip wheel
|
||||
pip install flake8 pytest pytest-subtests
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
|
||||
86
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
# This workflow will create a release and store builds to it when an x.y.z tag is pushed
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
|
||||
env:
|
||||
ENEMIZER_VERSION: 7.1
|
||||
APPIMAGETOOL_VERSION: 13
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
|
||||
with:
|
||||
draft: true # don't publish right away, especially since windows build is added by hand
|
||||
prerelease: false
|
||||
name: Archipelago ${{ env.RELEASE_VERSION }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# build-release-windows: # this is done by hand because of signing
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-release-ubuntu1804:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
# - code below copied from build.yml -
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
||||
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
|
||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||
- name: Build
|
||||
run: |
|
||||
# pygobject is an optional dependency for kivy that's not in requirements
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - code above copied from build.yml -
|
||||
- name: Add to Release
|
||||
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
|
||||
with:
|
||||
draft: true # see above
|
||||
prerelease: false
|
||||
name: Archipelago ${{ env.RELEASE_VERSION }}
|
||||
files: |
|
||||
dist/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
41
.github/workflows/unittests.yml
vendored
@@ -3,33 +3,52 @@
|
||||
|
||||
name: unittests
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'setup.py'
|
||||
- '*.iss'
|
||||
- '.gitignore'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'setup.py'
|
||||
- '*.iss'
|
||||
- '.gitignore'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: Test Python ${{ matrix.python.version }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python:
|
||||
- {version: '3.8'}
|
||||
- {version: '3.9'}
|
||||
#- {version: '3.10'}
|
||||
- {version: '3.10'}
|
||||
include:
|
||||
- python: {version: '3.8'} # win7 compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.10'} # current
|
||||
os: windows-latest
|
||||
- python: {version: '3.10'} # current
|
||||
os: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python.version }}
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python.version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8 pytest
|
||||
python ModuleUpdate.py --yes --force
|
||||
python -m pip install --upgrade pip wheel
|
||||
pip install flake8 pytest pytest-subtests
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
- name: Unittests
|
||||
run: |
|
||||
pytest test
|
||||
pytest
|
||||
|
||||
31
.gitignore
vendored
@@ -4,15 +4,21 @@
|
||||
*_Spoiler.txt
|
||||
*.bmbp
|
||||
*.apbp
|
||||
*.apl2ac
|
||||
*.apm3
|
||||
*.apmc
|
||||
*.apz5
|
||||
*.aptloz
|
||||
*.pyc
|
||||
*.pyd
|
||||
*.sfc
|
||||
*.z64
|
||||
*.n64
|
||||
*.nes
|
||||
*.sms
|
||||
*.gb
|
||||
*.gbc
|
||||
*.gba
|
||||
*.wixobj
|
||||
*.lck
|
||||
*.db3
|
||||
@@ -28,6 +34,7 @@ README.html
|
||||
.vs/
|
||||
EnemizerCLI/
|
||||
/Players/
|
||||
/SNI/
|
||||
/options.yaml
|
||||
/config.yaml
|
||||
/logs/
|
||||
@@ -43,7 +50,8 @@ Output Logs/
|
||||
/freeze_requirements.txt
|
||||
/Archipelago.zip
|
||||
/setup.ini
|
||||
|
||||
/installdelete.iss
|
||||
/data/user.kv
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -77,6 +85,7 @@ MANIFEST
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
installer.log
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
@@ -115,17 +124,22 @@ target/
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# vim editor
|
||||
*.swp
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
.venv*
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.code-workspace
|
||||
shell.nix
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
@@ -151,9 +165,18 @@ dmypy.json
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
#minecraft server stuff
|
||||
# minecraft server stuff
|
||||
jdk*/
|
||||
minecraft*/
|
||||
minecraft_versions.json
|
||||
!worlds/minecraft/
|
||||
|
||||
#pyenv
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# OS General Files
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Thumbs.db
|
||||
[Dd]esktop.ini
|
||||
|
||||
1170
BaseClasses.py
172
ChecksFinderClient.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import shutil
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("ChecksFinderClient", exception_logger="Client")
|
||||
|
||||
from NetUtils import NetworkItem, ClientStatus
|
||||
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
||||
CommonContext, server_loop
|
||||
|
||||
|
||||
class ChecksFinderClientCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.output(f"Syncing items.")
|
||||
self.ctx.syncing = True
|
||||
|
||||
|
||||
class ChecksFinderContext(CommonContext):
|
||||
command_processor: int = ChecksFinderClientCommandProcessor
|
||||
game = "ChecksFinder"
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(ChecksFinderContext, self).__init__(server_address, password)
|
||||
self.send_index: int = 0
|
||||
self.syncing = False
|
||||
self.awaiting_bridge = False
|
||||
# self.game_communication_path: files go in this path to pass data between us and the actual game
|
||||
if "localappdata" in os.environ:
|
||||
self.game_communication_path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
||||
else:
|
||||
# not windows. game is an exe so let's see if wine might be around to run it
|
||||
if "WINEPREFIX" in os.environ:
|
||||
wineprefix = os.environ["WINEPREFIX"]
|
||||
elif shutil.which("wine") or shutil.which("wine-stable"):
|
||||
wineprefix = os.path.expanduser("~/.wine") # default root of wine system data, deep in which is app data
|
||||
else:
|
||||
msg = "ChecksFinderClient couldn't detect system type. Unable to infer required game_communication_path"
|
||||
logger.error("Error: " + msg)
|
||||
Utils.messagebox("Error", msg, error=True)
|
||||
sys.exit(1)
|
||||
self.game_communication_path = os.path.join(
|
||||
wineprefix,
|
||||
"drive_c",
|
||||
os.path.expandvars("users/$USER/Local Settings/Application Data/ChecksFinder"))
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(ChecksFinderContext, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
async def connection_closed(self):
|
||||
await super(ChecksFinderContext, self).connection_closed()
|
||||
for root, dirs, files in os.walk(self.game_communication_path):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root + "/" + file)
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
if self.server:
|
||||
return [self.server]
|
||||
else:
|
||||
return []
|
||||
|
||||
async def shutdown(self):
|
||||
await super(ChecksFinderContext, self).shutdown()
|
||||
for root, dirs, files in os.walk(self.game_communication_path):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root+"/"+file)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
if not os.path.exists(self.game_communication_path):
|
||||
os.makedirs(self.game_communication_path)
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.close()
|
||||
if cmd in {"ReceivedItems"}:
|
||||
start_index = args["index"]
|
||||
if start_index != len(self.items_received):
|
||||
for item in args['items']:
|
||||
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.write(str(NetworkItem(*item).item))
|
||||
f.close()
|
||||
|
||||
if cmd in {"RoomUpdate"}:
|
||||
if "checked_locations" in args:
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.close()
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager
|
||||
|
||||
class ChecksFinderManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago ChecksFinder Client"
|
||||
|
||||
self.ui = ChecksFinderManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
async def game_watcher(ctx: ChecksFinderContext):
|
||||
from worlds.checksfinder.Locations import lookup_id_to_name
|
||||
while not ctx.exit_event.is_set():
|
||||
if ctx.syncing == True:
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
ctx.syncing = False
|
||||
sending = []
|
||||
victory = False
|
||||
for root, dirs, files in os.walk(ctx.game_communication_path):
|
||||
for file in files:
|
||||
if file.find("send") > -1:
|
||||
st = file.split("send", -1)[1]
|
||||
sending = sending+[(int(st))]
|
||||
if file.find("victory") > -1:
|
||||
victory = True
|
||||
ctx.locations_checked = sending
|
||||
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
||||
await ctx.send_msgs(message)
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
async def main(args):
|
||||
ctx = ChecksFinderContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
progression_watcher = asyncio.create_task(
|
||||
game_watcher(ctx), name="ChecksFinderProgressionWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await progression_watcher
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="ChecksFinder Client, for text interfacing.")
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
540
CommonClient.py
@@ -5,6 +5,10 @@ import urllib.parse
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
import functools
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import websockets
|
||||
|
||||
@@ -14,13 +18,18 @@ if __name__ == "__main__":
|
||||
Utils.init_logging("TextClient", exception_logger="Client")
|
||||
|
||||
from MultiServer import CommandProcessor
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
|
||||
from Utils import Version, stream_input
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
|
||||
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
|
||||
from Utils import Version, stream_input, async_start
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
import os
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import kvui
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
# without terminal we have to use gui mode
|
||||
# without terminal, we have to use gui mode
|
||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
||||
|
||||
|
||||
@@ -38,21 +47,25 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
|
||||
def _cmd_connect(self, address: str = "") -> bool:
|
||||
"""Connect to a MultiWorld Server"""
|
||||
self.ctx.server_address = None
|
||||
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
|
||||
if address:
|
||||
self.ctx.server_address = None
|
||||
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
|
||||
|
||||
def _cmd_disconnect(self) -> bool:
|
||||
"""Disconnect from a MultiWorld Server"""
|
||||
self.ctx.server_address = None
|
||||
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
||||
async_start(self.ctx.disconnect(), name="disconnecting")
|
||||
return True
|
||||
|
||||
def _cmd_received(self) -> bool:
|
||||
"""List all received items"""
|
||||
logger.info(f'{len(self.ctx.items_received)} received items:')
|
||||
self.output(f'{len(self.ctx.items_received)} received items:')
|
||||
for index, item in enumerate(self.ctx.items_received, 1):
|
||||
self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
|
||||
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
|
||||
return True
|
||||
|
||||
def _cmd_missing(self) -> bool:
|
||||
@@ -83,12 +96,18 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
|
||||
def _cmd_items(self):
|
||||
"""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}")
|
||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
||||
self.output(item_name)
|
||||
|
||||
def _cmd_locations(self):
|
||||
"""List all location names for the currently running game."""
|
||||
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}")
|
||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
||||
self.output(location_name)
|
||||
@@ -102,35 +121,79 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
else:
|
||||
state = ClientStatus.CLIENT_CONNECTED
|
||||
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):
|
||||
raw = self.ctx.on_user_say(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:
|
||||
# Should be adjusted as needed in subclasses
|
||||
tags: typing.Set[str] = {"AP"}
|
||||
game: typing.Optional[str] = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
want_slot_data: bool = True # should slot_data be retrieved via Connect
|
||||
|
||||
# datapackage
|
||||
# Contents in flux until connection to server is made, to download correct data for this multiworld.
|
||||
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||
|
||||
# defaults
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: int = ClientCommandProcessor
|
||||
game = None
|
||||
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
|
||||
ui = None
|
||||
keep_alive_task = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
ui_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
input_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
keep_alive_task: typing.Optional["asyncio.Task[None]"] = 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_version: Version = Version(0, 0, 0)
|
||||
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||
|
||||
# remaining type info
|
||||
slot_info: typing.Dict[int, NetworkSlot]
|
||||
server_address: typing.Optional[str]
|
||||
password: typing.Optional[str]
|
||||
hint_cost: typing.Optional[int]
|
||||
player_names: typing.Dict[int, str]
|
||||
|
||||
finished_game: bool
|
||||
ready: bool
|
||||
auth: typing.Optional[str]
|
||||
seed_name: typing.Optional[str]
|
||||
|
||||
# locations
|
||||
locations_checked: typing.Set[int] # local state
|
||||
locations_scouted: typing.Set[int]
|
||||
items_received: typing.List[NetworkItem]
|
||||
missing_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]
|
||||
|
||||
# internals
|
||||
# current message box through kvui
|
||||
_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: typing.Optional[str], password: typing.Optional[str]) -> None:
|
||||
# server state
|
||||
self.server_address = server_address
|
||||
self.username = None
|
||||
self.password = password
|
||||
self.server_task = None
|
||||
self.server: typing.Optional[Endpoint] = None
|
||||
self.server_version = Version(0, 0, 0)
|
||||
self.hint_cost: typing.Optional[int] = None
|
||||
self.games: typing.Dict[int, str] = {}
|
||||
self.hint_cost = None
|
||||
self.slot_info = {}
|
||||
self.permissions = {
|
||||
"forfeit": "disabled",
|
||||
"release": "disabled",
|
||||
"collect": "disabled",
|
||||
"remaining": "disabled",
|
||||
}
|
||||
@@ -143,30 +206,38 @@ class CommonContext():
|
||||
self.auth = None
|
||||
self.seed_name = None
|
||||
|
||||
self.locations_checked: typing.Set[int] = set() # local state
|
||||
self.locations_scouted: typing.Set[int] = set()
|
||||
self.locations_checked = set() # local state
|
||||
self.locations_scouted = set()
|
||||
self.items_received = []
|
||||
self.missing_locations: typing.Set[int] = set()
|
||||
self.checked_locations: typing.Set[int] = set() # server state
|
||||
self.missing_locations = set() # 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.input_queue = asyncio.Queue()
|
||||
self.input_requests = 0
|
||||
|
||||
self.last_death_link: float = time.time() # last send/received death link on AP layer
|
||||
|
||||
# game state
|
||||
self.player_names: typing.Dict[int: str] = {0: "Archipelago"}
|
||||
self.player_names = {0: "Archipelago"}
|
||||
self.exit_event = asyncio.Event()
|
||||
self.watcher_event = asyncio.Event()
|
||||
|
||||
self.slow_mode = False
|
||||
self.jsontotextparser = JSONtoTextParser(self)
|
||||
self.set_getters(network_data_package)
|
||||
self.update_datapackage(network_data_package)
|
||||
|
||||
# execution
|
||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||
|
||||
@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
|
||||
def total_locations(self) -> typing.Optional[int]:
|
||||
"""Will return None until connected."""
|
||||
@@ -174,58 +245,38 @@ class CommonContext():
|
||||
return len(self.checked_locations | self.missing_locations)
|
||||
|
||||
async def connection_closed(self):
|
||||
if self.server and self.server.socket is not None:
|
||||
await self.server.socket.close()
|
||||
self.reset_server_state()
|
||||
|
||||
def reset_server_state(self):
|
||||
self.auth = None
|
||||
self.slot = None
|
||||
self.team = None
|
||||
self.items_received = []
|
||||
self.locations_info = {}
|
||||
self.server_version = Version(0, 0, 0)
|
||||
if self.server and self.server.socket is not None:
|
||||
await self.server.socket.close()
|
||||
self.server = None
|
||||
self.server_task = None
|
||||
self.hint_cost = None
|
||||
self.permissions = {
|
||||
"release": "disabled",
|
||||
"collect": "disabled",
|
||||
"remaining": "disabled",
|
||||
}
|
||||
|
||||
# noinspection PyAttributeOutsideInit
|
||||
def set_getters(self, data_package: dict, network=False):
|
||||
if not network: # local data; check if newer data was already downloaded
|
||||
local_package = Utils.persistent_load().get("datapackage", {}).get("latest", {})
|
||||
if local_package and local_package["version"] > network_data_package["version"]:
|
||||
data_package: dict = local_package
|
||||
elif network: # check if data from server is newer
|
||||
|
||||
if data_package["version"] > network_data_package["version"]:
|
||||
Utils.persistent_store("datapackage", "latest", network_data_package)
|
||||
|
||||
item_lookup: dict = {}
|
||||
locations_lookup: dict = {}
|
||||
for game, gamedata in data_package["games"].items():
|
||||
for item_name, item_id in gamedata["item_name_to_id"].items():
|
||||
item_lookup[item_id] = item_name
|
||||
for location_name, location_id in gamedata["location_name_to_id"].items():
|
||||
locations_lookup[location_id] = location_name
|
||||
|
||||
def get_item_name_from_id(code: int):
|
||||
return item_lookup.get(code, f'Unknown item (ID:{code})')
|
||||
|
||||
self.item_name_getter = get_item_name_from_id
|
||||
|
||||
def get_location_name_from_address(address: int):
|
||||
return locations_lookup.get(address, f'Unknown location (ID:{address})')
|
||||
|
||||
self.location_name_getter = get_location_name_from_address
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
if self.server:
|
||||
return [self.server]
|
||||
else:
|
||||
return []
|
||||
|
||||
async def disconnect(self):
|
||||
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:
|
||||
await self.server.socket.close()
|
||||
if self.server_task is not None:
|
||||
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:
|
||||
return
|
||||
await self.server.socket.send(encode(msgs))
|
||||
@@ -246,25 +297,61 @@ class CommonContext():
|
||||
self.password = await self.console_input()
|
||||
return self.password
|
||||
|
||||
async def send_connect(self, **kwargs):
|
||||
async def get_username(self):
|
||||
if not self.auth:
|
||||
self.auth = self.username
|
||||
if not self.auth:
|
||||
logger.info('Enter slot name:')
|
||||
self.auth = await self.console_input()
|
||||
|
||||
async def send_connect(self, **kwargs: typing.Any) -> None:
|
||||
""" send `Connect` packet to log in to server """
|
||||
payload = {
|
||||
'cmd': 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
'tags': self.tags, 'items_handling': self.items_handling,
|
||||
'uuid': Utils.get_unique_identifier(), 'game': self.game
|
||||
'uuid': Utils.get_unique_identifier(), 'game': self.game, "slot_data": self.want_slot_data,
|
||||
}
|
||||
if kwargs:
|
||||
payload.update(kwargs)
|
||||
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
|
||||
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()
|
||||
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:
|
||||
if slot == self.slot:
|
||||
return True
|
||||
if slot in self.slot_info:
|
||||
return self.slot in self.slot_info[slot].group_members
|
||||
return False
|
||||
|
||||
def 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):
|
||||
logger.info(args["text"])
|
||||
|
||||
@@ -294,7 +381,9 @@ class CommonContext():
|
||||
logger.exception(e)
|
||||
|
||||
async def shutdown(self):
|
||||
self.server_address = None
|
||||
self.server_address = ""
|
||||
self.username = None
|
||||
self.cancel_autoreconnect()
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
if self.server_task:
|
||||
@@ -304,10 +393,60 @@ class CommonContext():
|
||||
self.input_queue.put_nowait(None)
|
||||
self.input_requests -= 1
|
||||
self.keep_alive_task.cancel()
|
||||
if self.ui_task:
|
||||
await self.ui_task
|
||||
if self.input_task:
|
||||
self.input_task.cancel()
|
||||
|
||||
# DataPackage
|
||||
async def prepare_datapackage(self, relevant_games: typing.Set[str],
|
||||
remote_datepackage_versions: typing.Dict[str, int]):
|
||||
"""Validate that all data is present for the current multiworld.
|
||||
Download, assimilate and cache missing data from the server."""
|
||||
# by documentation any game can use Archipelago locations/items -> always relevant
|
||||
relevant_games.add("Archipelago")
|
||||
|
||||
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||
needed_updates: typing.Set[str] = set()
|
||||
for game in relevant_games:
|
||||
if game not in remote_datepackage_versions:
|
||||
continue
|
||||
remote_version: int = remote_datepackage_versions[game]
|
||||
|
||||
if remote_version == 0: # custom datapackage for this game
|
||||
needed_updates.add(game)
|
||||
continue
|
||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
||||
# no action required if local version is new enough
|
||||
if remote_version > local_version:
|
||||
cache_version: int = cache_package.get(game, {}).get("version", 0)
|
||||
# download remote version if cache is not new enough
|
||||
if remote_version > cache_version:
|
||||
needed_updates.add(game)
|
||||
else:
|
||||
self.update_game(cache_package[game])
|
||||
if needed_updates:
|
||||
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
|
||||
|
||||
def update_game(self, game_package: dict):
|
||||
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||
self.item_names[item_id] = item_name
|
||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||
self.location_names[location_id] = location_name
|
||||
|
||||
def update_datapackage(self, data_package: dict):
|
||||
for game, gamedata in data_package["games"].items():
|
||||
self.update_game(gamedata)
|
||||
|
||||
def consume_network_datapackage(self, data_package: dict):
|
||||
self.update_datapackage(data_package)
|
||||
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||
current_cache.update(data_package["games"])
|
||||
Utils.persistent_store("datapackage", "games", current_cache)
|
||||
|
||||
# DeathLink hooks
|
||||
|
||||
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."""
|
||||
self.last_death_link = max(data["time"], self.last_death_link)
|
||||
text = data.get("cause", "")
|
||||
@@ -317,18 +456,19 @@ class CommonContext():
|
||||
logger.info(f"DeathLink: Received from {data['source']}")
|
||||
|
||||
async def send_death(self, death_text: str = ""):
|
||||
logger.info("DeathLink: Sending death to your friends...")
|
||||
self.last_death_link = time.time()
|
||||
await self.send_msgs([{
|
||||
"cmd": "Bounce", "tags": ["DeathLink"],
|
||||
"data": {
|
||||
"time": self.last_death_link,
|
||||
"source": self.player_names[self.slot],
|
||||
"cause": death_text
|
||||
}
|
||||
}])
|
||||
if self.server and self.server.socket:
|
||||
logger.info("DeathLink: Sending death to your friends...")
|
||||
self.last_death_link = time.time()
|
||||
await self.send_msgs([{
|
||||
"cmd": "Bounce", "tags": ["DeathLink"],
|
||||
"data": {
|
||||
"time": self.last_death_link,
|
||||
"source": self.player_names[self.slot],
|
||||
"cause": death_text
|
||||
}
|
||||
}])
|
||||
|
||||
async def update_death_link(self, death_link):
|
||||
async def update_death_link(self, death_link: bool):
|
||||
old_tags = self.tags.copy()
|
||||
if death_link:
|
||||
self.tags.add("DeathLink")
|
||||
@@ -337,6 +477,55 @@ class CommonContext():
|
||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||
|
||||
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
|
||||
"""Displays an error messagebox"""
|
||||
if not self.ui:
|
||||
return None
|
||||
title = title or "Error"
|
||||
from kvui import MessageBox
|
||||
if self._messagebox:
|
||||
self._messagebox.dismiss()
|
||||
# make "Multiple exceptions" look nice
|
||||
text = str(text).replace('[Errno', '\n[Errno').strip()
|
||||
# split long messages into title and text
|
||||
parts = title.split('. ', 1)
|
||||
if len(parts) == 1:
|
||||
parts = title.split(', ', 1)
|
||||
if len(parts) > 1:
|
||||
text = parts[1] + '\n\n' + text
|
||||
title = parts[0]
|
||||
# display error
|
||||
self._messagebox = MessageBox(title, text, error=True)
|
||||
self._messagebox.open()
|
||||
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):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager
|
||||
|
||||
class TextManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Text Client"
|
||||
|
||||
self.ui = TextManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def run_cli(self):
|
||||
if sys.stdin:
|
||||
# steam overlay breaks when starting console_loop
|
||||
if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''):
|
||||
logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.")
|
||||
else:
|
||||
self.input_task = asyncio.create_task(console_loop(self), name="Input")
|
||||
|
||||
|
||||
async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
|
||||
"""some ISPs/network configurations drop TCP connections if no payload is sent (ignore TCP-keep-alive)
|
||||
@@ -351,8 +540,7 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
|
||||
seconds_elapsed = 0
|
||||
|
||||
|
||||
async def server_loop(ctx: CommonContext, address=None):
|
||||
cached_address = None
|
||||
async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) -> None:
|
||||
if ctx.server and ctx.server.socket:
|
||||
logger.error('Already connected')
|
||||
return
|
||||
@@ -365,37 +553,60 @@ async def server_loop(ctx: CommonContext, address=None):
|
||||
logger.info('Please connect to an Archipelago server.')
|
||||
return
|
||||
|
||||
address = f"ws://{address}" if "://" not in address else address
|
||||
port = urllib.parse.urlparse(address).port or 38281
|
||||
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 \
|
||||
else address.replace("archipelago://", "ws://")
|
||||
|
||||
server_url = urllib.parse.urlparse(address)
|
||||
if server_url.username:
|
||||
ctx.username = server_url.username
|
||||
if server_url.password:
|
||||
ctx.password = server_url.password
|
||||
port = server_url.port or 38281
|
||||
|
||||
def reconnect_hint() -> str:
|
||||
return ", type /connect to reconnect" if ctx.server_address else ""
|
||||
|
||||
logger.info(f'Connecting to Archipelago server at {address}')
|
||||
try:
|
||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
||||
if ctx.ui is not None:
|
||||
ctx.ui.update_address_bar(server_url.netloc)
|
||||
ctx.server = Endpoint(socket)
|
||||
logger.info('Connected')
|
||||
ctx.server_address = address
|
||||
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
|
||||
ctx.disconnected_intentionally = False
|
||||
async for data in ctx.server.socket:
|
||||
for msg in decode(data):
|
||||
await process_server_cmd(ctx, msg)
|
||||
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
|
||||
except ConnectionRefusedError:
|
||||
if cached_address:
|
||||
logger.error('Unable to connect to multiworld server at cached address. '
|
||||
'Please use the connect button above.')
|
||||
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}")
|
||||
except websockets.InvalidMessage:
|
||||
# probably encrypted
|
||||
if address.startswith("ws://"):
|
||||
await server_loop(ctx, "ws" + address[1:])
|
||||
else:
|
||||
logger.exception('Connection refused by the multiworld server')
|
||||
ctx.handle_connection_loss(f"Lost connection to the multiworld server due to InvalidMessage"
|
||||
f"{reconnect_hint()}")
|
||||
except ConnectionRefusedError:
|
||||
ctx.handle_connection_loss("Connection refused by the server. "
|
||||
"May not be running Archipelago on that address or port.")
|
||||
except websockets.InvalidURI:
|
||||
logger.exception('Failed to connect to the multiworld server (invalid URI)')
|
||||
except (OSError, websockets.InvalidURI):
|
||||
logger.exception('Failed to connect to the multiworld server')
|
||||
except Exception as e:
|
||||
logger.exception('Lost connection to the multiworld server, type /connect to reconnect')
|
||||
ctx.handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
|
||||
except OSError:
|
||||
ctx.handle_connection_loss("Failed to connect to the multiworld server")
|
||||
except Exception:
|
||||
ctx.handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
|
||||
finally:
|
||||
await ctx.connection_closed()
|
||||
if ctx.server_address:
|
||||
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
|
||||
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
||||
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally:
|
||||
logger.info(f"... automatically reconnecting in {ctx.current_reconnect_delay} seconds")
|
||||
assert ctx.autoreconnect_task is None
|
||||
ctx.autoreconnect_task = asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
||||
ctx.current_reconnect_delay *= 2
|
||||
|
||||
|
||||
@@ -413,7 +624,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
raise
|
||||
if cmd == 'RoomInfo':
|
||||
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
|
||||
logger.info("The server is running a different multiworld than your client is. (invalid seed_name)")
|
||||
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
else:
|
||||
logger.info('--------------------------------')
|
||||
logger.info('Room Information:')
|
||||
@@ -427,8 +640,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
if args['password']:
|
||||
logger.info('Password required')
|
||||
ctx.update_permissions(args.get("permissions", {}))
|
||||
if "games" in args:
|
||||
ctx.games = {x: game for x, game in enumerate(args["games"], start=1)}
|
||||
logger.info(
|
||||
f"A !hint costs {args['hint_cost']}% of your total location count as points"
|
||||
f" and you get {args['location_check_points']}"
|
||||
@@ -436,24 +647,28 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.hint_cost = int(args['hint_cost'])
|
||||
ctx.check_points = int(args['location_check_points'])
|
||||
|
||||
if len(args['players']) < 1:
|
||||
logger.info('No player connected')
|
||||
else:
|
||||
args['players'].sort()
|
||||
current_team = -1
|
||||
logger.info('Players:')
|
||||
for network_player in args['players']:
|
||||
if network_player.team != current_team:
|
||||
logger.info(f' Team #{network_player.team + 1}')
|
||||
current_team = network_player.team
|
||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
|
||||
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
|
||||
if "players" in args: # TODO remove when servers sending this are outdated
|
||||
players = args.get("players", [])
|
||||
if len(players) < 1:
|
||||
logger.info('No player connected')
|
||||
else:
|
||||
players.sort()
|
||||
current_team = -1
|
||||
logger.info('Connected Players:')
|
||||
for network_player in players:
|
||||
if network_player.team != current_team:
|
||||
logger.info(f' Team #{network_player.team + 1}')
|
||||
current_team = network_player.team
|
||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||
|
||||
# update datapackage
|
||||
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
|
||||
|
||||
await ctx.server_auth(args['password'])
|
||||
|
||||
elif cmd == 'DataPackage':
|
||||
logger.info("Got new ID/Name Datapackage")
|
||||
ctx.set_getters(args['data'], network=True)
|
||||
logger.info("Got new ID/Name DataPackage")
|
||||
ctx.consume_network_datapackage(args['data'])
|
||||
|
||||
elif cmd == 'ConnectionRefused':
|
||||
errors = args["errors"]
|
||||
@@ -461,8 +676,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.event_invalid_slot()
|
||||
elif 'InvalidGame' in errors:
|
||||
ctx.event_invalid_game()
|
||||
elif 'SlotAlreadyTaken' in errors:
|
||||
raise Exception('Player slot already in use for that team')
|
||||
elif 'IncompatibleVersion' in errors:
|
||||
raise Exception('Server reported your client version as incompatible')
|
||||
elif 'InvalidItemsHandling' in errors:
|
||||
@@ -478,8 +691,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
raise Exception('Connection refused by the multiworld host, no reason provided')
|
||||
|
||||
elif cmd == 'Connected':
|
||||
ctx.username = ctx.auth
|
||||
ctx.team = args["team"]
|
||||
ctx.slot = args["slot"]
|
||||
# int keys get lost in JSON transfer
|
||||
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
|
||||
ctx.consume_players_package(args["players"])
|
||||
msgs = []
|
||||
if ctx.locations_checked:
|
||||
@@ -499,6 +715,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
# when /missing is used for the client side view of what is missing.
|
||||
ctx.missing_locations = set(args["missing_locations"])
|
||||
ctx.checked_locations = set(args["checked_locations"])
|
||||
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':
|
||||
start_index = args["index"]
|
||||
@@ -517,9 +737,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == 'LocationInfo':
|
||||
for item, location, player in args['locations']:
|
||||
if location not in ctx.locations_info:
|
||||
ctx.locations_info[location] = (item, player)
|
||||
for item in [NetworkItem(*item) for item in args['locations']]:
|
||||
ctx.locations_info[item.location] = item
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == "RoomUpdate":
|
||||
@@ -548,7 +767,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
|
||||
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
|
||||
ctx.on_deathlink(args["data"])
|
||||
|
||||
elif cmd == "SetReply":
|
||||
if args["key"] == "EnergyLink":
|
||||
ctx.current_energy_link_value = args["value"]
|
||||
if ctx.ui:
|
||||
ctx.ui.set_new_energy_link_value()
|
||||
else:
|
||||
logger.debug(f"unknown command {cmd}")
|
||||
|
||||
@@ -556,7 +779,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
|
||||
|
||||
async def console_loop(ctx: CommonContext):
|
||||
import sys
|
||||
commandprocessor = ctx.command_processor(ctx)
|
||||
queue = asyncio.Queue()
|
||||
stream_input(sys.stdin, queue)
|
||||
@@ -576,7 +798,7 @@ async def console_loop(ctx: CommonContext):
|
||||
logger.exception(e)
|
||||
|
||||
|
||||
def get_base_parser(description=None):
|
||||
def get_base_parser(description: typing.Optional[str] = None):
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||
@@ -590,53 +812,55 @@ if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
|
||||
class TextContext(CommonContext):
|
||||
tags = {"AP", "IgnoreGame", "TextOnly"}
|
||||
game = "Archipelago"
|
||||
items_handling = 0 # don't receive any NetworkItems
|
||||
tags = {"AP", "TextOnly"}
|
||||
game = "" # empty matches any game since 0.3.2
|
||||
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):
|
||||
if password_requested and not self.password:
|
||||
await super(TextContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
logger.info('Enter slot name:')
|
||||
self.auth = await self.console_input()
|
||||
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.games.get(self.slot, None)
|
||||
self.game = self.slot_info[self.slot].game
|
||||
|
||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||
self.game = ""
|
||||
await super().disconnect(allow_autoreconnect)
|
||||
|
||||
|
||||
async def main(args):
|
||||
ctx = TextContext(args.connect, args.password)
|
||||
ctx.auth = args.name
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
input_task = None
|
||||
|
||||
if gui_enabled:
|
||||
from kvui import TextManager
|
||||
ctx.ui = TextManager(ctx)
|
||||
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
|
||||
else:
|
||||
ui_task = None
|
||||
if sys.stdin:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
|
||||
await ctx.shutdown()
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
||||
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.url:
|
||||
url = urllib.parse.urlparse(args.url)
|
||||
args.connect = url.netloc
|
||||
if url.username:
|
||||
args.name = urllib.parse.unquote(url.username)
|
||||
if url.password:
|
||||
args.password = urllib.parse.unquote(url.password)
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
115
FF1Client.py
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
@@ -6,7 +7,8 @@ from typing import List
|
||||
|
||||
|
||||
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
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
@@ -18,6 +20,8 @@ CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
DISPLAY_MSGS = True
|
||||
|
||||
|
||||
class FF1CommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
@@ -28,9 +32,16 @@ class FF1CommandProcessor(ClientCommandProcessor):
|
||||
if isinstance(self.ctx, FF1Context):
|
||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
||||
|
||||
def _cmd_toggle_msgs(self):
|
||||
"""Toggle displaying messages in bizhawk"""
|
||||
global DISPLAY_MSGS
|
||||
DISPLAY_MSGS = not DISPLAY_MSGS
|
||||
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
||||
|
||||
|
||||
class FF1Context(CommonContext):
|
||||
command_processor = FF1CommandProcessor
|
||||
game = 'Final Fantasy'
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
@@ -40,8 +51,8 @@ class FF1Context(CommonContext):
|
||||
self.messages = {}
|
||||
self.locations_array = None
|
||||
self.nes_status = CONNECTION_INITIAL_STATUS
|
||||
self.game = 'Final Fantasy'
|
||||
self.awaiting_rom = False
|
||||
self.display_msgs = True
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -54,44 +65,52 @@ class FF1Context(CommonContext):
|
||||
await self.send_connect()
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
self.messages[(time.time(), msg_id)] = msg
|
||||
if DISPLAY_MSGS:
|
||||
self.messages[time.time(), msg_id] = msg
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.game = self.games.get(self.slot, None)
|
||||
asyncio.create_task(parse_locations(self.locations_array, self, True))
|
||||
async_start(parse_locations(self.locations_array, self, True))
|
||||
elif cmd == 'Print':
|
||||
msg = args['text']
|
||||
if ': !' not in msg:
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == "ReceivedItems":
|
||||
msg = f"Received {', '.join([self.item_name_getter(item.item) for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == 'PrintJSON':
|
||||
print_type = args['type']
|
||||
item = args['item']
|
||||
receiving_player_id = args['receiving']
|
||||
receiving_player_name = self.player_names[receiving_player_id]
|
||||
sending_player_id = item.player
|
||||
sending_player_name = self.player_names[item.player]
|
||||
if print_type == 'Hint':
|
||||
msg = f"Hint: Your {self.item_name_getter(item.item)} is at" \
|
||||
f" {self.player_names[item.player]}'s {self.location_name_getter(item.location)}"
|
||||
self._set_message(msg, item.item)
|
||||
elif print_type == 'ItemSend' and receiving_player_id != self.slot:
|
||||
if sending_player_id == self.slot:
|
||||
if receiving_player_id == self.slot:
|
||||
msg = f"You found your own {self.item_name_getter(item.item)}"
|
||||
else:
|
||||
msg = f"You sent {self.item_name_getter(item.item)} to {receiving_player_name}"
|
||||
else:
|
||||
if receiving_player_id == sending_player_id:
|
||||
msg = f"{sending_player_name} found their {self.item_name_getter(item.item)}"
|
||||
else:
|
||||
msg = f"{sending_player_name} sent {self.item_name_getter(item.item)} to " \
|
||||
f"{receiving_player_name}"
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if self.ui:
|
||||
self.ui.print_json(copy.deepcopy(args["data"]))
|
||||
else:
|
||||
text = self.jsontotextparser(copy.deepcopy(args["data"]))
|
||||
logger.info(text)
|
||||
relevant = args.get("type", None) in {"Hint", "ItemSend"}
|
||||
if relevant:
|
||||
item = args["item"]
|
||||
# goes to this world
|
||||
if self.slot_concerns_self(args["receiving"]):
|
||||
relevant = True
|
||||
# found in this world
|
||||
elif self.slot_concerns_self(item.player):
|
||||
relevant = True
|
||||
# not related
|
||||
else:
|
||||
relevant = False
|
||||
if relevant:
|
||||
item = args["item"]
|
||||
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
|
||||
self._set_message(msg, item.item)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class FF1Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Final Fantasy 1 Client"
|
||||
|
||||
self.ui = FF1Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
def get_payload(ctx: FF1Context):
|
||||
current_time = time.time()
|
||||
@@ -129,13 +148,13 @@ async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bo
|
||||
index -= 0x200
|
||||
flag = 0x02
|
||||
|
||||
# print(f"Location: {ctx.location_name_getter(location)}")
|
||||
# print(f"Location: {ctx.location_names[location]}")
|
||||
# print(f"Index: {str(hex(index))}")
|
||||
# print(f"value: {locations_array[index] & flag != 0}")
|
||||
if locations_array[index] & flag != 0:
|
||||
locations_checked.append(location)
|
||||
if locations_checked:
|
||||
# print([ctx.location_name_getter(location) for location in locations_checked])
|
||||
# print([ctx.location_names[location] for location in locations_checked])
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "LocationChecks",
|
||||
"locations": locations_checked}
|
||||
@@ -162,9 +181,12 @@ async def nes_sync_task(ctx: FF1Context):
|
||||
# print(data_decoded)
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
|
||||
async_start(parse_locations(data_decoded['locations'], ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||
if ctx.auth == '':
|
||||
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
|
||||
"the ROM using the same link but adding your slot name")
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
except asyncio.TimeoutError:
|
||||
@@ -215,18 +237,15 @@ if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
Utils.init_logging("FF1Client")
|
||||
|
||||
options = Utils.get_options()
|
||||
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
|
||||
|
||||
async def main(args):
|
||||
ctx = FF1Context(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
input_task = None
|
||||
from kvui import FF1Manager
|
||||
ctx.ui = FF1Manager(ctx)
|
||||
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
|
||||
else:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
ui_task = None
|
||||
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
@@ -237,20 +256,12 @@ if __name__ == '__main__':
|
||||
if ctx.nes_sync_task:
|
||||
await ctx.nes_sync_task
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser()
|
||||
args, rest = parser.parse_known_args()
|
||||
args = parser.parse_args()
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
@@ -4,10 +4,15 @@ import logging
|
||||
import json
|
||||
import string
|
||||
import copy
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
import typing
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import factorio_rcon
|
||||
import colorama
|
||||
@@ -15,13 +20,18 @@ import asyncio
|
||||
from queue import Queue
|
||||
import Utils
|
||||
|
||||
def check_stdin() -> None:
|
||||
if Utils.is_windows and sys.stdin:
|
||||
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||
check_stdin()
|
||||
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
|
||||
get_base_parser
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
|
||||
from MultiServer import mark_raw
|
||||
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||
from Utils import async_start
|
||||
|
||||
from worlds.factorio import Factorio
|
||||
|
||||
@@ -29,6 +39,10 @@ from worlds.factorio import Factorio
|
||||
class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
ctx: FactorioContext
|
||||
|
||||
def _cmd_energy_link(self):
|
||||
"""Print the status of the energy link."""
|
||||
self.output(f"Energy Link: {self.ctx.energy_link_status}")
|
||||
|
||||
@mark_raw
|
||||
def _cmd_factorio(self, text: str) -> bool:
|
||||
"""Send the following command to the bound Factorio Server."""
|
||||
@@ -45,6 +59,13 @@ class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
"""Manually trigger a resync."""
|
||||
self.ctx.awaiting_bridge = True
|
||||
|
||||
def _cmd_toggle_send_filter(self):
|
||||
"""Toggle filtering of item sends that get displayed in-game to only those that involve you."""
|
||||
self.ctx.toggle_filter_item_sends()
|
||||
|
||||
def _cmd_toggle_chat(self):
|
||||
"""Toggle sending of chat messages from players on the Factorio server to Archipelago."""
|
||||
self.ctx.toggle_bridge_chat_out()
|
||||
|
||||
class FactorioContext(CommonContext):
|
||||
command_processor = FactorioCommandProcessor
|
||||
@@ -62,6 +83,11 @@ class FactorioContext(CommonContext):
|
||||
self.write_data_path = None
|
||||
self.death_link_tick: int = 0 # last send death link on Factorio layer
|
||||
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
||||
self.energy_link_increment = 0
|
||||
self.last_deplete = 0
|
||||
self.filter_item_sends: bool = False
|
||||
self.multiplayer: bool = False # whether multiple different players have connected
|
||||
self.bridge_chat_out: bool = True
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -78,12 +104,16 @@ class FactorioContext(CommonContext):
|
||||
def on_print(self, args: dict):
|
||||
super(FactorioContext, self).on_print(args)
|
||||
if self.rcon_client:
|
||||
self.print_to_game(args['text'])
|
||||
if not args['text'].startswith(self.player_names[self.slot] + ":"):
|
||||
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)
|
||||
if (not self.filter_item_sends or not self.is_uninteresting_item_send(args)) \
|
||||
and not self.is_echoed_chat(args):
|
||||
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
|
||||
if not text.startswith(self.player_names[self.slot] + ":"): # TODO: Remove string heuristic in the future.
|
||||
self.print_to_game(text)
|
||||
super(FactorioContext, self).on_print_json(args)
|
||||
|
||||
@property
|
||||
@@ -94,6 +124,15 @@ class FactorioContext(CommonContext):
|
||||
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
||||
f"{text}")
|
||||
|
||||
@property
|
||||
def energy_link_status(self) -> str:
|
||||
if not self.energy_link_increment:
|
||||
return "Disabled"
|
||||
elif self.current_energy_link_value is None:
|
||||
return "Standby"
|
||||
else:
|
||||
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J"
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
if self.rcon_client:
|
||||
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
|
||||
@@ -105,19 +144,88 @@ class FactorioContext(CommonContext):
|
||||
if "checked_locations" in args and args["checked_locations"]:
|
||||
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
||||
item_name in args["checked_locations"]})
|
||||
if cmd == "Connected" and self.energy_link_increment:
|
||||
async_start(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 on_user_say(self, text: str) -> typing.Optional[str]:
|
||||
# Mirror chat sent from the UI to the Factorio server.
|
||||
self.print_to_game(f"{self.player_names[self.slot]}: {text}")
|
||||
return text
|
||||
|
||||
async def chat_from_factorio(self, user: str, message: str) -> None:
|
||||
if not self.bridge_chat_out:
|
||||
return
|
||||
|
||||
# Pass through commands
|
||||
if message.startswith("!"):
|
||||
await self.send_msgs([{"cmd": "Say", "text": message}])
|
||||
return
|
||||
|
||||
# Omit messages that contain local coordinates
|
||||
if "[gps=" in message:
|
||||
return
|
||||
|
||||
prefix = f"({user}) " if self.multiplayer else ""
|
||||
await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}])
|
||||
|
||||
def toggle_filter_item_sends(self) -> None:
|
||||
self.filter_item_sends = not self.filter_item_sends
|
||||
if self.filter_item_sends:
|
||||
announcement = "Item sends are now filtered."
|
||||
else:
|
||||
announcement = "Item sends are no longer filtered."
|
||||
logger.info(announcement)
|
||||
self.print_to_game(announcement)
|
||||
|
||||
def toggle_bridge_chat_out(self) -> None:
|
||||
self.bridge_chat_out = not self.bridge_chat_out
|
||||
if self.bridge_chat_out:
|
||||
announcement = "Chat is now bridged to Archipelago."
|
||||
else:
|
||||
announcement = "Chat is no longer bridged to Archipelago."
|
||||
logger.info(announcement)
|
||||
self.print_to_game(announcement)
|
||||
|
||||
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():
|
||||
if ctx.awaiting_bridge and ctx.rcon_client and time.perf_counter() > next_bridge:
|
||||
# TODO: restore on-demand refresh
|
||||
if ctx.rcon_client and time.perf_counter() > next_bridge:
|
||||
next_bridge = time.perf_counter() + 1
|
||||
ctx.awaiting_bridge = False
|
||||
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
|
||||
if data["slot_name"] != ctx.auth:
|
||||
if not ctx.auth:
|
||||
pass # auth failed, wait for new attempt
|
||||
elif data["slot_name"] != ctx.auth:
|
||||
bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
|
||||
elif data["seed_name"] != ctx.seed_name:
|
||||
bridge_logger.warning(
|
||||
@@ -127,8 +235,8 @@ async def game_watcher(ctx: FactorioContext):
|
||||
research_data = data["research_done"]
|
||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||
victory = data["victory"]
|
||||
if "death_link" in data: # TODO: Remove this if statement around version 0.2.4 or so
|
||||
await ctx.update_death_link(data["death_link"])
|
||||
await ctx.update_death_link(data["death_link"])
|
||||
ctx.multiplayer = data.get("multiplayer", False)
|
||||
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
@@ -137,14 +245,38 @@ async def game_watcher(ctx: FactorioContext):
|
||||
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]}")
|
||||
f"{[ctx.location_names[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:
|
||||
await ctx.send_death()
|
||||
async_start(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()
|
||||
async_start(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
|
||||
async_start(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)
|
||||
|
||||
@@ -154,6 +286,8 @@ async def game_watcher(ctx: FactorioContext):
|
||||
|
||||
|
||||
def stream_factorio_output(pipe, queue, process):
|
||||
pipe.reconfigure(errors="replace")
|
||||
|
||||
def queuer():
|
||||
while process.poll() is None:
|
||||
text = pipe.readline().strip()
|
||||
@@ -186,7 +320,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
||||
try:
|
||||
while not ctx.exit_event.is_set():
|
||||
if factorio_process.poll():
|
||||
if factorio_process.poll() is not None:
|
||||
factorio_server_logger.info("Factorio server has exited.")
|
||||
ctx.exit_event.set()
|
||||
|
||||
@@ -199,12 +333,25 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
if not ctx.server:
|
||||
logger.info("Established bridge to Factorio Server. "
|
||||
"Ready to connect to Archipelago via /connect")
|
||||
check_stdin()
|
||||
|
||||
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
|
||||
ctx.awaiting_bridge = True
|
||||
factorio_server_logger.debug(msg)
|
||||
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command energy-link$", msg):
|
||||
factorio_server_logger.debug(msg)
|
||||
ctx.print_to_game(f"Energy Link: {ctx.energy_link_status}")
|
||||
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter$", msg):
|
||||
factorio_server_logger.debug(msg)
|
||||
ctx.toggle_filter_item_sends()
|
||||
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg):
|
||||
factorio_server_logger.debug(msg)
|
||||
ctx.toggle_bridge_chat_out()
|
||||
else:
|
||||
factorio_server_logger.info(msg)
|
||||
match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg)
|
||||
if match:
|
||||
await ctx.chat_from_factorio(match.group(1), match.group(2))
|
||||
if ctx.rcon_client:
|
||||
commands = {}
|
||||
while ctx.send_index < len(ctx.items_received):
|
||||
@@ -225,20 +372,46 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
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)
|
||||
if factorio_process.poll() is not None:
|
||||
if ctx.rcon_client:
|
||||
ctx.rcon_client.close()
|
||||
ctx.rcon_client = None
|
||||
return
|
||||
|
||||
sent_quit = False
|
||||
if ctx.rcon_client:
|
||||
# Attempt clean quit through RCON.
|
||||
try:
|
||||
ctx.rcon_client.send_command("/quit")
|
||||
except factorio_rcon.RCONNetworkError:
|
||||
pass
|
||||
else:
|
||||
sent_quit = True
|
||||
ctx.rcon_client.close()
|
||||
ctx.rcon_client = None
|
||||
if not sent_quit:
|
||||
# Attempt clean quit using SIGTERM. (Note that on Windows this kills the process instead.)
|
||||
factorio_process.terminate()
|
||||
|
||||
try:
|
||||
factorio_process.wait(10)
|
||||
except subprocess.TimeoutExpired:
|
||||
factorio_process.kill()
|
||||
|
||||
|
||||
async def get_info(ctx, rcon_client):
|
||||
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
|
||||
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
|
||||
ctx.auth = info["slot_name"]
|
||||
ctx.seed_name = info["seed_name"]
|
||||
# 0.2.0 addition, not present earlier
|
||||
death_link = bool(info.get("death_link", False))
|
||||
ctx.energy_link_increment = info.get("energy_link", 0)
|
||||
logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}")
|
||||
if ctx.energy_link_increment and ctx.ui:
|
||||
ctx.ui.enable_energy_link()
|
||||
await ctx.update_death_link(death_link)
|
||||
|
||||
|
||||
@@ -282,8 +455,10 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
logger.error("Aborted Factorio Server Bridge")
|
||||
logger.exception(e, extra={"compact_gui": True})
|
||||
msg = "Aborted Factorio Server Bridge"
|
||||
logger.error(msg)
|
||||
ctx.gui_error(msg, e)
|
||||
ctx.exit_event.set()
|
||||
|
||||
else:
|
||||
@@ -298,16 +473,14 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
|
||||
async def main(args):
|
||||
ctx = FactorioContext(args.connect, args.password)
|
||||
ctx.filter_item_sends = initial_filter_item_sends
|
||||
ctx.bridge_chat_out = initial_bridge_chat_out
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
input_task = None
|
||||
|
||||
if gui_enabled:
|
||||
from kvui import FactorioManager
|
||||
ctx.ui = FactorioManager(ctx)
|
||||
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
|
||||
else:
|
||||
ui_task = None
|
||||
if sys.stdin:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
|
||||
successful_launch = await factorio_server_task
|
||||
if successful_launch:
|
||||
@@ -323,12 +496,6 @@ async def main(args):
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
|
||||
class FactorioJSONtoTextParser(JSONtoTextParser):
|
||||
def _handle_color(self, node: JSONMessagePart):
|
||||
@@ -346,6 +513,7 @@ if __name__ == '__main__':
|
||||
"Refer to Factorio --help for those.")
|
||||
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
|
||||
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
|
||||
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
@@ -356,6 +524,15 @@ if __name__ == '__main__':
|
||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||
options = Utils.get_options()
|
||||
executable = options["factorio_options"]["executable"]
|
||||
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
|
||||
if server_settings:
|
||||
server_settings = os.path.abspath(server_settings)
|
||||
if not isinstance(options["factorio_options"]["filter_item_sends"], bool):
|
||||
logging.warning(f"Warning: Option filter_item_sends should be a bool.")
|
||||
initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"])
|
||||
if not isinstance(options["factorio_options"]["bridge_chat_out"], bool):
|
||||
logging.warning(f"Warning: Option bridge_chat_out should be a bool.")
|
||||
initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"])
|
||||
|
||||
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.")
|
||||
@@ -367,9 +544,10 @@ if __name__ == '__main__':
|
||||
else:
|
||||
raise FileNotFoundError(f"Path {executable} is not an executable file.")
|
||||
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||
if server_settings and os.path.isfile(server_settings):
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, "--server-settings", server_settings, *rest)
|
||||
else:
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
676
Fill.py
@@ -4,17 +4,17 @@ import collections
|
||||
import itertools
|
||||
from collections import Counter, deque
|
||||
|
||||
|
||||
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item
|
||||
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item, ItemClassification
|
||||
|
||||
from worlds.AutoWorld import call_all
|
||||
from worlds.generic.Rules import add_item_rule
|
||||
|
||||
|
||||
class FillError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def sweep_from_pool(base_state: CollectionState, itempool=[]):
|
||||
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState:
|
||||
new_state = base_state.copy()
|
||||
for item in itempool:
|
||||
new_state.collect(item, True)
|
||||
@@ -22,13 +22,15 @@ def sweep_from_pool(base_state: CollectionState, itempool=[]):
|
||||
return new_state
|
||||
|
||||
|
||||
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations, itempool: typing.List[Item],
|
||||
single_player_placement=False, lock=False):
|
||||
unplaced_items = []
|
||||
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||
itempool: 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) -> None:
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
|
||||
swapped_items = Counter()
|
||||
reachable_items: typing.Dict[int, deque] = {}
|
||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
|
||||
for item in itempool:
|
||||
reachable_items.setdefault(item.player, deque()).append(item)
|
||||
|
||||
@@ -43,80 +45,95 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
|
||||
|
||||
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
|
||||
|
||||
for item_to_place in items_to_place:
|
||||
while items_to_place:
|
||||
# if we have run out of locations to fill,break out of this loop
|
||||
if not locations:
|
||||
unplaced_items += items_to_place
|
||||
break
|
||||
item_to_place = items_to_place.pop(0)
|
||||
|
||||
spot_to_fill: typing.Optional[Location] = None
|
||||
|
||||
# if minimal accessibility, only check whether location is reachable if game not beatable
|
||||
if world.accessibility[item_to_place.player] == 'minimal':
|
||||
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
|
||||
item_to_place.player) if single_player_placement else not has_beaten_game
|
||||
item_to_place.player) \
|
||||
if single_player_placement else not has_beaten_game
|
||||
else:
|
||||
perform_access_check = True
|
||||
|
||||
for i, location in enumerate(locations):
|
||||
if (not single_player_placement or location.player == item_to_place.player) \
|
||||
and location.can_fill(maximum_exploration_state, item_to_place, perform_access_check):
|
||||
# poping by index is faster than removing by content,
|
||||
# popping by index is faster than removing by content,
|
||||
spot_to_fill = locations.pop(i)
|
||||
# skipping a scan for the element
|
||||
break
|
||||
|
||||
else:
|
||||
# we filled all reachable spots.
|
||||
# try swapping this item with previously placed items
|
||||
for(i, location) in enumerate(placements):
|
||||
placed_item = location.item
|
||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||
# number of times we will swap an individual item to prevent this
|
||||
swap_count = swapped_items[placed_item.player,
|
||||
placed_item.name]
|
||||
if swap_count > 1:
|
||||
if swap:
|
||||
# try swapping this item with previously placed items
|
||||
for (i, location) in enumerate(placements):
|
||||
placed_item = location.item
|
||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||
# number of times we will swap an individual item to prevent this
|
||||
swap_count = swapped_items[placed_item.player,
|
||||
placed_item.name]
|
||||
if swap_count > 1:
|
||||
continue
|
||||
|
||||
location.item = None
|
||||
placed_item.location = None
|
||||
swap_state = sweep_from_pool(base_state, [placed_item])
|
||||
# swap_state assumes we can collect placed item before item_to_place
|
||||
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, which could happen with rules
|
||||
# that want to not have both items. Left in until removal is proven useful.
|
||||
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] = swap_count
|
||||
|
||||
reachable_items[placed_item.player].appendleft(
|
||||
placed_item)
|
||||
itempool.append(placed_item)
|
||||
|
||||
break
|
||||
|
||||
# Item can't be placed here, restore original item
|
||||
location.item = placed_item
|
||||
placed_item.location = location
|
||||
|
||||
if spot_to_fill is None:
|
||||
# Can't place this item, move on to the next
|
||||
unplaced_items.append(item_to_place)
|
||||
continue
|
||||
|
||||
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
|
||||
else:
|
||||
unplaced_items.append(item_to_place)
|
||||
continue
|
||||
|
||||
world.push_item(spot_to_fill, item_to_place, False)
|
||||
spot_to_fill.locked = lock
|
||||
placements.append(spot_to_fill)
|
||||
spot_to_fill.event = item_to_place.advancement
|
||||
if on_place:
|
||||
on_place(spot_to_fill)
|
||||
|
||||
if len(unplaced_items) > 0 and len(locations) > 0:
|
||||
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
|
||||
if world.can_beat_game():
|
||||
logging.warning(
|
||||
@@ -128,33 +145,216 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
|
||||
itempool.extend(unplaced_items)
|
||||
|
||||
|
||||
def distribute_items_restrictive(world: MultiWorld):
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
fill_locations = sorted(world.get_unfilled_locations())
|
||||
world.random.shuffle(fill_locations)
|
||||
|
||||
# get items to distribute
|
||||
itempool = sorted(world.itempool)
|
||||
world.random.shuffle(itempool)
|
||||
progitempool = []
|
||||
nonexcludeditempool = []
|
||||
localrestitempool = {player: [] for player in range(1, world.players + 1)}
|
||||
nonlocalrestitempool = []
|
||||
restitempool = []
|
||||
|
||||
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
|
||||
|
||||
progitempool: typing.List[Item] = []
|
||||
usefulitempool: typing.List[Item] = []
|
||||
filleritempool: typing.List[Item] = []
|
||||
|
||||
for item in itempool:
|
||||
if item.advancement:
|
||||
progitempool.append(item)
|
||||
elif item.never_exclude: # this only gets nonprogression items which should not appear in excluded locations
|
||||
nonexcludeditempool.append(item)
|
||||
elif item.name in world.local_items[item.player].value:
|
||||
localrestitempool[item.player].append(item)
|
||||
elif item.name in world.non_local_items[item.player].value:
|
||||
nonlocalrestitempool.append(item)
|
||||
elif item.useful:
|
||||
usefulitempool.append(item)
|
||||
else:
|
||||
restitempool.append(item)
|
||||
filleritempool.append(item)
|
||||
|
||||
call_all(world, "fill_hook", progitempool, nonexcludeditempool,
|
||||
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
|
||||
call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
|
||||
|
||||
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
|
||||
loc_type: [] for loc_type in LocationProgressType}
|
||||
@@ -166,81 +366,58 @@ def distribute_items_restrictive(world: MultiWorld):
|
||||
defaultlocations = locations[LocationProgressType.DEFAULT]
|
||||
excludedlocations = locations[LocationProgressType.EXCLUDED]
|
||||
|
||||
fill_restrictive(world, world.state, prioritylocations, progitempool)
|
||||
# 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:
|
||||
# "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
|
||||
|
||||
if progitempool:
|
||||
# "progression fill"
|
||||
fill_restrictive(world, world.state, defaultlocations, progitempool)
|
||||
if progitempool:
|
||||
raise FillError(
|
||||
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
|
||||
accessibility_corrections(world, world.state, defaultlocations)
|
||||
|
||||
if nonexcludeditempool:
|
||||
world.random.shuffle(defaultlocations)
|
||||
# needs logical fill to not conflict with local items
|
||||
fill_restrictive(
|
||||
world, world.state, defaultlocations, nonexcludeditempool)
|
||||
if nonexcludeditempool:
|
||||
raise FillError(
|
||||
f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations')
|
||||
for location in lock_later:
|
||||
if location.item:
|
||||
location.locked = True
|
||||
del mark_for_locking, lock_later
|
||||
|
||||
defaultlocations = defaultlocations + excludedlocations
|
||||
world.random.shuffle(defaultlocations)
|
||||
inaccessible_location_rules(world, world.state, defaultlocations)
|
||||
|
||||
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
|
||||
local_locations = {player: [] for player in world.player_ids}
|
||||
for location in defaultlocations:
|
||||
local_locations[location.player].append(location)
|
||||
for player_locations in local_locations.values():
|
||||
world.random.shuffle(player_locations)
|
||||
remaining_fill(world, excludedlocations, filleritempool)
|
||||
if excludedlocations:
|
||||
raise FillError(
|
||||
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
|
||||
|
||||
for player, items in localrestitempool.items(): # items already shuffled
|
||||
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)
|
||||
restitempool = usefulitempool + filleritempool
|
||||
|
||||
for item_to_place in nonlocalrestitempool:
|
||||
for i, location in enumerate(defaultlocations):
|
||||
if location.player != item_to_place.player:
|
||||
world.push_item(defaultlocations.pop(i), item_to_place, False)
|
||||
break
|
||||
else:
|
||||
logging.warning(
|
||||
f"Could not place non_local_item {item_to_place} among {defaultlocations}, tossing.")
|
||||
remaining_fill(world, defaultlocations, restitempool)
|
||||
|
||||
world.random.shuffle(defaultlocations)
|
||||
|
||||
restitempool, defaultlocations = fast_fill(
|
||||
world, restitempool, defaultlocations)
|
||||
unplaced = progitempool + restitempool
|
||||
unfilled = [location.name for location in defaultlocations]
|
||||
unplaced = restitempool
|
||||
unfilled = defaultlocations
|
||||
|
||||
if unplaced or unfilled:
|
||||
logging.warning(
|
||||
f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
|
||||
items_counter = Counter([location.item.player for location in world.get_locations()])
|
||||
locations_counter = Counter([location.player for location in world.get_locations()])
|
||||
items_counter.update([item.player for item in unplaced])
|
||||
locations_counter.update([location.player for location in unfilled])
|
||||
items_counter = Counter(location.item.player for location in world.get_locations() if location.item)
|
||||
locations_counter = Counter(location.player for location in world.get_locations())
|
||||
items_counter.update(item.player for item in unplaced)
|
||||
locations_counter.update(location.player for location in unfilled)
|
||||
print_data = {"items": items_counter, "locations": locations_counter}
|
||||
logging.info(f'Per-Player counts: {print_data})')
|
||||
|
||||
|
||||
def fast_fill(world: MultiWorld, item_pool: typing.List, fill_locations: typing.List) -> typing.Tuple[typing.List, typing.List]:
|
||||
placing = min(len(item_pool), len(fill_locations))
|
||||
for item, location in zip(item_pool, fill_locations):
|
||||
world.push_item(location, item, False)
|
||||
return item_pool[placing:], fill_locations[placing:]
|
||||
|
||||
|
||||
def flood_items(world: MultiWorld):
|
||||
def flood_items(world: MultiWorld) -> None:
|
||||
# get items to distribute
|
||||
world.random.shuffle(world.itempool)
|
||||
itempool = world.itempool
|
||||
@@ -279,7 +456,8 @@ def flood_items(world: MultiWorld):
|
||||
item_to_place = item
|
||||
break
|
||||
|
||||
# we might be in a situation where all new locations require multiple items to reach. If that is the case, just place any advancement item we've found and continue trying
|
||||
# we might be in a situation where all new locations require multiple items to reach.
|
||||
# If that is the case, just place any advancement item we've found and continue trying
|
||||
if item_to_place is None:
|
||||
if candidate_item_to_place is not None:
|
||||
item_to_place = candidate_item_to_place
|
||||
@@ -300,71 +478,135 @@ def flood_items(world: MultiWorld):
|
||||
break
|
||||
|
||||
|
||||
def balance_multiworld_progression(world: MultiWorld):
|
||||
balanceable_players = {player for player in range(1, world.players + 1) if world.progression_balancing[player]}
|
||||
def balance_multiworld_progression(world: MultiWorld) -> None:
|
||||
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
|
||||
# Overall progression balancing algorithm:
|
||||
# Gather up all locations in a sphere.
|
||||
# Define a threshold value based on the player with the most available locations.
|
||||
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
|
||||
# which gives more locations available by this sphere.
|
||||
balanceable_players: typing.Dict[int, float] = {
|
||||
player: world.progression_balancing[player] / 100
|
||||
for player in world.player_ids
|
||||
if world.progression_balancing[player] > 0
|
||||
}
|
||||
if not balanceable_players:
|
||||
logging.info('Skipping multiworld progression balancing.')
|
||||
else:
|
||||
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
|
||||
state = CollectionState(world)
|
||||
checked_locations = set()
|
||||
unchecked_locations = set(world.get_locations())
|
||||
logging.debug(balanceable_players)
|
||||
state: CollectionState = CollectionState(world)
|
||||
checked_locations: typing.Set[Location] = set()
|
||||
unchecked_locations: typing.Set[Location] = set(world.get_locations())
|
||||
|
||||
reachable_locations_count = {player: 0 for player in world.get_all_ids()}
|
||||
reachable_locations_count: typing.Dict[int, int] = {
|
||||
player: 0
|
||||
for player in world.player_ids
|
||||
if len(world.get_filled_locations(player)) != 0
|
||||
}
|
||||
total_locations_count: typing.Counter[int] = Counter(
|
||||
location.player
|
||||
for location in world.get_locations()
|
||||
if not location.locked
|
||||
)
|
||||
balanceable_players = {
|
||||
player: balanceable_players[player]
|
||||
for player in balanceable_players
|
||||
if total_locations_count[player]
|
||||
}
|
||||
sphere_num: int = 1
|
||||
moved_item_count: int = 0
|
||||
|
||||
def get_sphere_locations(sphere_state, locations):
|
||||
def get_sphere_locations(sphere_state: CollectionState,
|
||||
locations: typing.Set[Location]) -> typing.Set[Location]:
|
||||
sphere_state.sweep_for_events(key_only=True, locations=locations)
|
||||
return {loc for loc in locations if sphere_state.can_reach(loc)}
|
||||
|
||||
def item_percentage(player: int, num: int) -> float:
|
||||
return num / total_locations_count[player]
|
||||
|
||||
while True:
|
||||
# Gather non-locked locations.
|
||||
# This ensures that only shuffled locations get counted for progression balancing,
|
||||
# i.e. the items the players will be checking.
|
||||
sphere_locations = get_sphere_locations(state, unchecked_locations)
|
||||
for location in sphere_locations:
|
||||
unchecked_locations.remove(location)
|
||||
reachable_locations_count[location.player] += 1
|
||||
if not location.locked:
|
||||
reachable_locations_count[location.player] += 1
|
||||
|
||||
logging.debug(f"Sphere {sphere_num}")
|
||||
logging.debug(f"Reachable locations: {reachable_locations_count}")
|
||||
debug_percentages = {
|
||||
player: round(item_percentage(player, num), 2)
|
||||
for player, num in reachable_locations_count.items()
|
||||
}
|
||||
logging.debug(f"Reachable percentages: {debug_percentages}\n")
|
||||
sphere_num += 1
|
||||
|
||||
if checked_locations:
|
||||
threshold = max(reachable_locations_count.values()) - 20
|
||||
balancing_players = {player for player, reachables in reachable_locations_count.items() if
|
||||
reachables < threshold and player in balanceable_players}
|
||||
max_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]),
|
||||
reachable_locations_count))
|
||||
threshold_percentages = {
|
||||
player: max_percentage * balanceable_players[player]
|
||||
for player in balanceable_players
|
||||
}
|
||||
logging.debug(f"Thresholds: {threshold_percentages}")
|
||||
balancing_players = {
|
||||
player
|
||||
for player, reachables in reachable_locations_count.items()
|
||||
if (player in threshold_percentages
|
||||
and item_percentage(player, reachables) < threshold_percentages[player])
|
||||
}
|
||||
if balancing_players:
|
||||
balancing_state = state.copy()
|
||||
balancing_unchecked_locations = unchecked_locations.copy()
|
||||
balancing_reachables = reachable_locations_count.copy()
|
||||
balancing_sphere = sphere_locations.copy()
|
||||
candidate_items = collections.defaultdict(set)
|
||||
candidate_items: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||
while True:
|
||||
# Check locations in the current sphere and gather progression items to swap earlier
|
||||
for location in balancing_sphere:
|
||||
if location.event:
|
||||
balancing_state.collect(location.item, True, location)
|
||||
player = location.item.player
|
||||
# only replace items that end up in another player's world
|
||||
if(not location.locked and
|
||||
if (not location.locked and not location.item.skip_in_prog_balancing and
|
||||
player in balancing_players and
|
||||
location.player != player and
|
||||
location.progress_type != LocationProgressType.PRIORITY):
|
||||
candidate_items[player].add(location)
|
||||
logging.debug(f"Candidate item: {location.name}, {location.item.name}")
|
||||
balancing_sphere = get_sphere_locations(balancing_state, balancing_unchecked_locations)
|
||||
for location in balancing_sphere:
|
||||
balancing_unchecked_locations.remove(location)
|
||||
balancing_reachables[location.player] += 1
|
||||
if not location.locked:
|
||||
balancing_reachables[location.player] += 1
|
||||
if world.has_beaten_game(balancing_state) or all(
|
||||
reachables >= threshold for reachables in balancing_reachables.values()):
|
||||
item_percentage(player, reachables) >= threshold_percentages[player]
|
||||
for player, reachables in balancing_reachables.items()
|
||||
if player in threshold_percentages):
|
||||
break
|
||||
elif not balancing_sphere:
|
||||
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
||||
unlocked_locations = collections.defaultdict(set)
|
||||
# Gather a set of locations which we can swap items into
|
||||
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||
for l in unchecked_locations:
|
||||
if l not in balancing_unchecked_locations:
|
||||
unlocked_locations[l.player].add(l)
|
||||
items_to_replace = []
|
||||
items_to_replace: typing.List[Location] = []
|
||||
for player in balancing_players:
|
||||
locations_to_test = unlocked_locations[player]
|
||||
items_to_test = candidate_items[player]
|
||||
items_to_test = list(candidate_items[player])
|
||||
items_to_test.sort()
|
||||
world.random.shuffle(items_to_test)
|
||||
while items_to_test:
|
||||
testing = items_to_test.pop()
|
||||
reducing_state = state.copy()
|
||||
for location in itertools.chain((l for l in items_to_replace if l.item.player == player),
|
||||
items_to_test):
|
||||
for location in itertools.chain((
|
||||
l for l in items_to_replace
|
||||
if l.item.player == player
|
||||
), items_to_test):
|
||||
reducing_state.collect(location.item, True, location)
|
||||
|
||||
reducing_state.sweep_for_events(locations=locations_to_test)
|
||||
@@ -374,7 +616,8 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
items_to_replace.append(testing)
|
||||
else:
|
||||
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
|
||||
if reachable_locations_count[player] + len(reduced_sphere) < threshold:
|
||||
p = item_percentage(player, reachable_locations_count[player] + len(reduced_sphere))
|
||||
if p < threshold_percentages[player]:
|
||||
items_to_replace.append(testing)
|
||||
|
||||
replaced_items = False
|
||||
@@ -386,6 +629,7 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
items_to_replace.sort()
|
||||
world.random.shuffle(items_to_replace)
|
||||
|
||||
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
|
||||
while replacement_locations and items_to_replace:
|
||||
old_location = items_to_replace.pop()
|
||||
for new_location in replacement_locations:
|
||||
@@ -395,6 +639,7 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
swap_location_item(old_location, new_location)
|
||||
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
|
||||
f"displacing {old_location.item} into {old_location}")
|
||||
moved_item_count += 1
|
||||
state.collect(new_location.item, True, new_location)
|
||||
replaced_items = True
|
||||
break
|
||||
@@ -402,10 +647,12 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
logging.warning(f"Could not Progression Balance {old_location.item}")
|
||||
|
||||
if replaced_items:
|
||||
logging.debug(f"Moved {moved_item_count} items so far\n")
|
||||
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
|
||||
for location in get_sphere_locations(state, unlocked):
|
||||
unchecked_locations.remove(location)
|
||||
reachable_locations_count[location.player] += 1
|
||||
if not location.locked:
|
||||
reachable_locations_count[location.player] += 1
|
||||
sphere_locations.add(location)
|
||||
|
||||
for location in sphere_locations:
|
||||
@@ -420,7 +667,7 @@ def balance_multiworld_progression(world: MultiWorld):
|
||||
break
|
||||
|
||||
|
||||
def swap_location_item(location_1: Location, location_2: Location, check_locked=True):
|
||||
def swap_location_item(location_1: Location, location_2: Location, check_locked: bool = True) -> None:
|
||||
"""Swaps Items of locations. Does NOT swap flags like shop_slot or locked, but does swap event"""
|
||||
if check_locked:
|
||||
if location_1.locked:
|
||||
@@ -433,24 +680,36 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked=
|
||||
location_1.event, location_2.event = location_2.event, location_1.event
|
||||
|
||||
|
||||
def distribute_planned(world: MultiWorld):
|
||||
def warn(warning: str, force):
|
||||
def distribute_planned(world: MultiWorld) -> None:
|
||||
def warn(warning: str, force: typing.Union[bool, str]) -> None:
|
||||
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
|
||||
logging.warning(f'{warning}')
|
||||
else:
|
||||
logging.debug(f'{warning}')
|
||||
|
||||
def failed(warning: str, force):
|
||||
if force in [True, 'fail', 'failure']:
|
||||
def failed(warning: str, force: typing.Union[bool, str]) -> None:
|
||||
if force in [True, 'fail', 'failure']:
|
||||
raise Exception(warning)
|
||||
else:
|
||||
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
|
||||
from worlds.alttp.Regions import key_drop_data
|
||||
world_name_lookup = world.world_name_lookup
|
||||
|
||||
plando_blocks = []
|
||||
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
|
||||
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
|
||||
player_ids = set(world.player_ids)
|
||||
for player in player_ids:
|
||||
for block in world.plando_items[player]:
|
||||
@@ -460,8 +719,40 @@ def distribute_planned(world: MultiWorld):
|
||||
if 'from_pool' not in block:
|
||||
block['from_pool'] = True
|
||||
if 'world' not in block:
|
||||
block['world'] = False
|
||||
items = []
|
||||
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 = []
|
||||
if "items" in block:
|
||||
items = block["items"]
|
||||
if 'count' not in block:
|
||||
@@ -474,7 +765,7 @@ def distribute_planned(world: MultiWorld):
|
||||
failed("You must specify at least one item to place items with plando.", block['force'])
|
||||
continue
|
||||
if isinstance(items, dict):
|
||||
item_list = []
|
||||
item_list: typing.List[str] = []
|
||||
for key, value in items.items():
|
||||
if value is True:
|
||||
value = world.itempool.count(world.worlds[player].create_item(key))
|
||||
@@ -484,7 +775,7 @@ def distribute_planned(world: MultiWorld):
|
||||
items = [items]
|
||||
block['items'] = items
|
||||
|
||||
locations = []
|
||||
locations: block_value = []
|
||||
if 'location' in block:
|
||||
locations = block['location'] # just allow 'location' to keep old yamls compatible
|
||||
elif 'locations' in block:
|
||||
@@ -497,20 +788,29 @@ def distribute_planned(world: MultiWorld):
|
||||
for key, value in locations.items():
|
||||
location_list += [key] * value
|
||||
locations = location_list
|
||||
if isinstance(locations, str):
|
||||
locations = [locations]
|
||||
|
||||
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
|
||||
|
||||
if not block['count']:
|
||||
block['count'] = (min(len(block['items']), len(block['locations'])) if len(block['locations'])
|
||||
> 0 else len(block['items']))
|
||||
block['count'] = (min(len(block['items']), len(block['locations'])) if
|
||||
len(block['locations']) > 0 else len(block['items']))
|
||||
if isinstance(block['count'], int):
|
||||
block['count'] = {'min': block['count'], 'max': block['count']}
|
||||
if 'min' not in block['count']:
|
||||
block['count']['min'] = 0
|
||||
if 'max' not in block['count']:
|
||||
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if len(block['locations'])
|
||||
> 0 else len(block['items']))
|
||||
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
|
||||
len(block['locations']) > 0 else len(block['items']))
|
||||
if block['count']['max'] > len(block['items']):
|
||||
count = block['count']
|
||||
failed(f"Plando count {count} greater than items specified", block['force'])
|
||||
@@ -534,47 +834,18 @@ def distribute_planned(world: MultiWorld):
|
||||
for placement in plando_blocks:
|
||||
player = placement['player']
|
||||
try:
|
||||
target_world = placement['world']
|
||||
worlds = placement['world']
|
||||
locations = placement['locations']
|
||||
items = placement['items']
|
||||
maxcount = placement['count']['target']
|
||||
from_pool = placement['from_pool']
|
||||
if target_world is False or world.players == 1: # target own world
|
||||
worlds = {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 = []
|
||||
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.append(world_name_lookup[listed_world])
|
||||
worlds = set(worlds)
|
||||
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,
|
||||
worlds))
|
||||
candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
|
||||
world.random.shuffle(candidates)
|
||||
world.random.shuffle(items)
|
||||
count = 0
|
||||
err = []
|
||||
successful_pairs = []
|
||||
err: typing.List[str] = []
|
||||
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
|
||||
for item_name in items:
|
||||
item = world.worlds[player].create_item(item_name)
|
||||
for location in reversed(candidates):
|
||||
@@ -585,7 +856,7 @@ def distribute_planned(world: MultiWorld):
|
||||
if not location.item:
|
||||
if location.item_rule(item):
|
||||
if location.can_fill(world.state, item, False):
|
||||
successful_pairs.append([item, location])
|
||||
successful_pairs.append((item, location))
|
||||
candidates.remove(location)
|
||||
count = count + 1
|
||||
break
|
||||
@@ -598,10 +869,9 @@ def distribute_planned(world: MultiWorld):
|
||||
if count == maxcount:
|
||||
break
|
||||
if count < placement['count']['min']:
|
||||
err = " ".join(err)
|
||||
m = placement['count']['min']
|
||||
failed(
|
||||
f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {err}",
|
||||
f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}",
|
||||
placement['force'])
|
||||
for (item, location) in successful_pairs:
|
||||
world.push_item(location, item, collect=False)
|
||||
|
||||
294
Generate.py
@@ -1,12 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import typing
|
||||
import os
|
||||
from collections import Counter
|
||||
import random
|
||||
import string
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter, ChainMap
|
||||
from typing import Dict, Tuple, Callable, Any, Union
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
@@ -15,36 +17,38 @@ ModuleUpdate.update()
|
||||
import Utils
|
||||
from worlds.alttp import Options as LttPOptions
|
||||
from worlds.generic import PlandoConnection
|
||||
from Utils import parse_yaml, version_tuple, __version__, tuplize_version, get_options
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, user_path
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
import Options
|
||||
from worlds.alttp import Bosses
|
||||
from worlds.alttp.Text import TextTable
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import copy
|
||||
|
||||
categories = set(AutoWorldRegister.world_types)
|
||||
|
||||
|
||||
|
||||
|
||||
def mystery_argparse():
|
||||
options = get_options()
|
||||
defaults = options["generator"]
|
||||
|
||||
def resolve_path(path: str, resolver: Callable[[str], str]) -> str:
|
||||
return path if os.path.isabs(path) else resolver(path)
|
||||
|
||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||
parser.add_argument('--weights_file_path', default = defaults["weights_file_path"],
|
||||
parser.add_argument('--weights_file_path', default=defaults["weights_file_path"],
|
||||
help='Path to the weights file to use for rolling game settings, urls are also valid')
|
||||
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
|
||||
action='store_true')
|
||||
parser.add_argument('--player_files_path', default=defaults["player_files_path"],
|
||||
parser.add_argument('--player_files_path', default=resolve_path(defaults["player_files_path"], user_path),
|
||||
help="Input directory for player files.")
|
||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
||||
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
|
||||
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
|
||||
parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"], help="Path to the 1.0 JP LttP Baserom.")
|
||||
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"], help="Path to the 1.0 JP SM Baserom.")
|
||||
parser.add_argument('--enemizercli', default=defaults["enemizer_path"])
|
||||
parser.add_argument('--outputpath', default=options["general_options"]["output_path"])
|
||||
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
|
||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||
parser.add_argument('--race', action='store_true', default=defaults["race"])
|
||||
parser.add_argument('--meta_file_path', default=defaults["meta_file_path"])
|
||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
||||
@@ -57,7 +61,7 @@ def mystery_argparse():
|
||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||
if not os.path.isabs(args.meta_file_path):
|
||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||
args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
|
||||
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||
return args, options
|
||||
|
||||
|
||||
@@ -76,23 +80,25 @@ def main(args=None, callback=ERmain):
|
||||
if args.race:
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
weights_cache = {}
|
||||
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
||||
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
||||
try:
|
||||
weights_cache[args.weights_file_path] = read_weights_yaml(args.weights_file_path)
|
||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
|
||||
print(f"Weights: {args.weights_file_path} >> "
|
||||
f"{get_choice('description', weights_cache[args.weights_file_path], 'No description specified')}")
|
||||
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
|
||||
|
||||
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
||||
try:
|
||||
weights_cache[args.meta_file_path] = read_weights_yaml(args.meta_file_path)
|
||||
meta_weights = read_weights_yamls(args.meta_file_path)[-1]
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
|
||||
meta_weights = weights_cache[args.meta_file_path]
|
||||
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
||||
del(meta_weights["meta_description"])
|
||||
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
|
||||
del(meta_weights["meta_description"])
|
||||
except Exception as e:
|
||||
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
|
||||
if args.samesettings:
|
||||
raise Exception("Cannot mix --samesettings with --meta")
|
||||
else:
|
||||
@@ -101,27 +107,34 @@ def main(args=None, callback=ERmain):
|
||||
player_files = {}
|
||||
for file in os.scandir(args.player_files_path):
|
||||
fname = file.name
|
||||
if file.is_file() and os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||
if file.is_file() and not fname.startswith(".") and \
|
||||
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||
path = os.path.join(args.player_files_path, fname)
|
||||
try:
|
||||
weights_cache[fname] = read_weights_yaml(path)
|
||||
weights_cache[fname] = read_weights_yamls(path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e
|
||||
else:
|
||||
print(f"P{player_id} Weights: {fname} >> "
|
||||
f"{get_choice('description', weights_cache[fname], 'No description specified')}")
|
||||
player_files[player_id] = fname
|
||||
|
||||
# sort dict for consistent results across platforms:
|
||||
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
||||
for filename, yaml_data in weights_cache.items():
|
||||
if filename not in {args.meta_file_path, args.weights_file_path}:
|
||||
for yaml in yaml_data:
|
||||
print(f"P{player_id} Weights: {filename} >> "
|
||||
f"{get_choice('description', yaml, 'No description specified')}")
|
||||
player_files[player_id] = filename
|
||||
player_id += 1
|
||||
|
||||
args.multi = max(player_id-1, args.multi)
|
||||
args.multi = max(player_id - 1, args.multi)
|
||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
||||
f"{', '.join(args.plando)}")
|
||||
f"{args.plando}")
|
||||
|
||||
if not weights_cache:
|
||||
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||
f"A mix is also permitted.")
|
||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||
erargs.seed = seed
|
||||
erargs.plando_options = args.plando
|
||||
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
|
||||
erargs.spoiler = args.spoiler
|
||||
erargs.race = args.race
|
||||
@@ -130,57 +143,63 @@ def main(args=None, callback=ERmain):
|
||||
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||
|
||||
erargs.lttp_rom = args.lttp_rom
|
||||
erargs.sm_rom = args.sm_rom
|
||||
erargs.enemizercli = args.enemizercli
|
||||
|
||||
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
|
||||
for k, v in weights_cache.items()}
|
||||
player_path_cache = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
|
||||
for fname, yamls in weights_cache.items()}
|
||||
|
||||
if meta_weights:
|
||||
for category_name, category_dict in meta_weights.items():
|
||||
for key in category_dict:
|
||||
option = get_choice(key, category_dict)
|
||||
option = roll_meta_option(key, category_name, category_dict)
|
||||
if option is not None:
|
||||
for player, path in player_path_cache.items():
|
||||
if category_name is None:
|
||||
weights_cache[path][key] = option
|
||||
elif category_name not in weights_cache[path]:
|
||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||
else:
|
||||
weights_cache[path][category_name][key] = option
|
||||
for path in weights_cache:
|
||||
for yaml in weights_cache[path]:
|
||||
if category_name is None:
|
||||
for category in yaml:
|
||||
if category in AutoWorldRegister.world_types and key in Options.common_options:
|
||||
yaml[category][key] = option
|
||||
elif category_name not in yaml:
|
||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||
else:
|
||||
yaml[category_name][key] = option
|
||||
|
||||
player_path_cache = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||
name_counter = Counter()
|
||||
erargs.player_settings = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
|
||||
player = 1
|
||||
while player <= args.multi:
|
||||
path = player_path_cache[player]
|
||||
if path:
|
||||
try:
|
||||
settings = settings_cache[path] if settings_cache[path] else \
|
||||
roll_settings(weights_cache[path], args.plando)
|
||||
for k, v in vars(settings).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(erargs, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(erargs, k, {player: v})
|
||||
except Exception as e:
|
||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
||||
for settingsObject in settings:
|
||||
for k, v in vars(settingsObject).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(erargs, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(erargs, k, {player: v})
|
||||
except Exception as e:
|
||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||
|
||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||
erargs.name[player] = f"Player{player}"
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
player += 1
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||
else:
|
||||
raise RuntimeError(f'No weights specified for player {player}')
|
||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||
erargs.name[player] = f"Player{player}"
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
if len(set(erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
||||
|
||||
if args.yaml_output:
|
||||
import yaml
|
||||
@@ -207,17 +226,17 @@ def main(args=None, callback=ERmain):
|
||||
callback(erargs, seed)
|
||||
|
||||
|
||||
def read_weights_yaml(path):
|
||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
||||
try:
|
||||
if urllib.parse.urlparse(path).scheme:
|
||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8")
|
||||
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
||||
else:
|
||||
with open(path, 'rb') as f:
|
||||
yaml = str(f.read(), "utf-8")
|
||||
yaml = str(f.read(), "utf-8-sig")
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to read weights ({path})") from e
|
||||
|
||||
return parse_yaml(yaml)
|
||||
return tuple(parse_yamls(yaml))
|
||||
|
||||
|
||||
def interpret_on_off(value) -> bool:
|
||||
@@ -228,7 +247,7 @@ def convert_to_on_off(value) -> str:
|
||||
return {True: "on", False: "off"}.get(value, value)
|
||||
|
||||
|
||||
def get_choice_legacy(option, root, value=None) -> typing.Any:
|
||||
def get_choice_legacy(option, root, value=None) -> Any:
|
||||
if option not in root:
|
||||
return value
|
||||
if type(root[option]) is list:
|
||||
@@ -243,7 +262,7 @@ def get_choice_legacy(option, root, value=None) -> typing.Any:
|
||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||
|
||||
|
||||
def get_choice(option, root, value=None) -> typing.Any:
|
||||
def get_choice(option, root, value=None) -> Any:
|
||||
if option not in root:
|
||||
return value
|
||||
if type(root[option]) is list:
|
||||
@@ -263,11 +282,11 @@ class SafeDict(dict):
|
||||
|
||||
|
||||
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 = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
|
||||
NUMBER=(name_counter[name] if name_counter[
|
||||
name] > 1 else ''),
|
||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
||||
NUMBER=(number if number > 1 else ''),
|
||||
player=player,
|
||||
PLAYER=(player if player > 1 else '')))
|
||||
new_name = new_name.strip()[:16]
|
||||
@@ -276,26 +295,13 @@ def handle_name(name: str, player: int, name_counter: Counter):
|
||||
return new_name
|
||||
|
||||
|
||||
def prefer_int(input_data: str) -> typing.Union[str, int]:
|
||||
def prefer_int(input_data: str) -> Union[str, int]:
|
||||
try:
|
||||
return int(input_data)
|
||||
except:
|
||||
return input_data
|
||||
|
||||
|
||||
available_boss_names: typing.Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
||||
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
||||
available_boss_locations: typing.Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
||||
Bosses.boss_location_table}
|
||||
|
||||
boss_shuffle_options = {None: 'none',
|
||||
'none': 'none',
|
||||
'basic': 'basic',
|
||||
'full': 'full',
|
||||
'chaos': 'chaos',
|
||||
'singularity': 'singularity'
|
||||
}
|
||||
|
||||
goals = {
|
||||
'ganon': 'ganon',
|
||||
'crystals': 'crystals',
|
||||
@@ -310,7 +316,7 @@ goals = {
|
||||
}
|
||||
|
||||
|
||||
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
|
||||
def roll_percentage(percentage: Union[int, float]) -> bool:
|
||||
"""Roll a percentage chance.
|
||||
percentage is expected to be in range [0, 100]"""
|
||||
return random.random() < (float(percentage) / 100)
|
||||
@@ -328,6 +334,28 @@ def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> di
|
||||
return weights
|
||||
|
||||
|
||||
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
||||
if not game:
|
||||
return get_choice(option_key, category_dict)
|
||||
if game in AutoWorldRegister.world_types:
|
||||
game_world = AutoWorldRegister.world_types[game]
|
||||
options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
|
||||
if option_key in options:
|
||||
if options[option_key].supports_weighting:
|
||||
return get_choice(option_key, category_dict)
|
||||
return category_dict[option_key]
|
||||
if game == "A Link to the Past": # TODO wow i hate this
|
||||
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
|
||||
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
|
||||
"triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
|
||||
"boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
|
||||
"red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
|
||||
"misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
|
||||
"random_sprite_on_event"}:
|
||||
return get_choice(option_key, category_dict)
|
||||
raise Exception(f"Error generating meta option {option_key} for {game}.")
|
||||
|
||||
|
||||
def roll_linked_options(weights: dict) -> dict:
|
||||
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
|
||||
for option_set in weights["linked_options"]:
|
||||
@@ -380,42 +408,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||
return weights
|
||||
|
||||
|
||||
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
|
||||
if boss_shuffle in boss_shuffle_options:
|
||||
return boss_shuffle_options[boss_shuffle]
|
||||
elif "bosses" in plando_options:
|
||||
options = boss_shuffle.lower().split(";")
|
||||
remainder_shuffle = "none" # vanilla
|
||||
bosses = []
|
||||
for boss in options:
|
||||
if boss in boss_shuffle_options:
|
||||
remainder_shuffle = boss_shuffle_options[boss]
|
||||
elif "-" in boss:
|
||||
loc, boss_name = boss.split("-")
|
||||
if boss_name not in available_boss_names:
|
||||
raise ValueError(f"Unknown Boss name {boss_name}")
|
||||
if loc not in available_boss_locations:
|
||||
raise ValueError(f"Unknown Boss Location {loc}")
|
||||
level = ''
|
||||
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
||||
# split off level
|
||||
loc = loc.split(" ")
|
||||
level = f" {loc[-1]}"
|
||||
loc = " ".join(loc[:-1])
|
||||
loc = loc.title().replace("Of", "of")
|
||||
if not Bosses.can_place_boss(boss_name.title(), loc, level):
|
||||
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
|
||||
bosses.append(boss)
|
||||
elif boss not in available_boss_names:
|
||||
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
|
||||
else:
|
||||
bosses.append(boss)
|
||||
return ";".join(bosses + [remainder_shuffle])
|
||||
else:
|
||||
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
|
||||
|
||||
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
||||
if option_key in game_weights:
|
||||
try:
|
||||
if not option.supports_weighting:
|
||||
@@ -426,13 +419,12 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
||||
except Exception as e:
|
||||
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
||||
else:
|
||||
if hasattr(player_option, "verify"):
|
||||
player_option.verify(AutoWorldRegister.world_types[ret.game])
|
||||
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||
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: typing.Set[str] = frozenset(("bosses",))):
|
||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
@@ -445,17 +437,11 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
|
||||
if tuplize_version(version) > version_tuple:
|
||||
raise Exception(f"Settings reports required version of generator is at least {version}, "
|
||||
f"however generator is of version {__version__}")
|
||||
required_plando_options = requirements.get("plando", "")
|
||||
if required_plando_options:
|
||||
required_plando_options = set(option.strip() for option in required_plando_options.split(","))
|
||||
required_plando_options -= plando_options
|
||||
required_plando_options = PlandoOptions.from_option_string(requirements.get("plando", ""))
|
||||
if required_plando_options not in plando_options:
|
||||
if required_plando_options:
|
||||
if len(required_plando_options) == 1:
|
||||
raise Exception(f"Settings reports required plando module {', '.join(required_plando_options)}, "
|
||||
f"which is not enabled.")
|
||||
else:
|
||||
raise Exception(f"Settings reports required plando modules {', '.join(required_plando_options)}, "
|
||||
f"which are not enabled.")
|
||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||
f"which is not enabled.")
|
||||
|
||||
ret = argparse.Namespace()
|
||||
for option_key in Options.per_game_common_options:
|
||||
@@ -478,18 +464,19 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
|
||||
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
|
||||
|
||||
if ret.game in AutoWorldRegister.world_types:
|
||||
for option_key, option in world_type.options.items():
|
||||
handle_option(ret, game_weights, option_key, option)
|
||||
for option_key, option in world_type.option_definitions.items():
|
||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||
for option_key, option in Options.per_game_common_options.items():
|
||||
# skip setting this option if already set from common_options, defaulting to root option
|
||||
if not (option_key in Options.common_options and option_key not in game_weights):
|
||||
handle_option(ret, game_weights, option_key, option)
|
||||
if "items" in plando_options:
|
||||
if option_key not in world_type.option_definitions and \
|
||||
(option_key not in Options.common_options or option_key in game_weights):
|
||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||
if PlandoOptions.items in plando_options:
|
||||
ret.plando_items = game_weights.get("plando_items", [])
|
||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||
# bad hardcoded behavior to make this work for now
|
||||
ret.plando_connections = []
|
||||
if "connections" in plando_options:
|
||||
if PlandoOptions.connections in plando_options:
|
||||
options = game_weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
@@ -502,9 +489,7 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
|
||||
roll_alttp_settings(ret, game_weights, plando_options)
|
||||
else:
|
||||
raise Exception(f"Unsupported game {ret.game}")
|
||||
# not meant to stay here, intended to be removed when itemlinks are stable
|
||||
if not "item_links" in plando_options:
|
||||
ret.item_links.value = []
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
@@ -537,9 +522,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
|
||||
ret.goal = goals[goal]
|
||||
|
||||
# TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when
|
||||
# fast ganon + ganon at hole
|
||||
ret.open_pyramid = get_choice_legacy('open_pyramid', weights, 'goal')
|
||||
|
||||
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
|
||||
|
||||
@@ -571,8 +553,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
|
||||
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
||||
|
||||
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
|
||||
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
|
||||
|
||||
ret.enemy_damage = {None: 'default',
|
||||
'default': 'default',
|
||||
@@ -611,7 +591,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||
|
||||
ret.plando_texts = {}
|
||||
if "texts" in plando_options:
|
||||
if PlandoOptions.texts in plando_options:
|
||||
tt = TextTable()
|
||||
tt.removeUnwantedText()
|
||||
options = weights.get("plando_texts", [])
|
||||
@@ -623,7 +603,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
||||
|
||||
ret.plando_connections = []
|
||||
if "connections" in plando_options:
|
||||
if PlandoOptions.connections in plando_options:
|
||||
options = weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
|
||||
307
Launcher.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
Archipelago launcher for bundled app.
|
||||
|
||||
* if run with APBP as argument, launch corresponding client.
|
||||
* if run with executable as argument, run it passing argv[2:] as arguments
|
||||
* if run without arguments, open launcher GUI
|
||||
|
||||
Scroll down to components= to add components to the launcher as well as setup.py
|
||||
"""
|
||||
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from enum import Enum, auto
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
from typing import Iterable, Sequence, Callable, Union, Optional
|
||||
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
|
||||
is_windows, is_macos, is_linux
|
||||
|
||||
|
||||
def open_host_yaml():
|
||||
file = user_path('host.yaml')
|
||||
if is_linux:
|
||||
exe = which('sensible-editor') or which('gedit') or \
|
||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
subprocess.Popen([exe, file])
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, file])
|
||||
else:
|
||||
import webbrowser
|
||||
webbrowser.open(file)
|
||||
|
||||
|
||||
def open_patch():
|
||||
suffixes = []
|
||||
for c in components:
|
||||
if isfile(get_exe(c)[-1]):
|
||||
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
|
||||
isinstance(c.file_identifier, SuffixIdentifier) else []
|
||||
try:
|
||||
filename = open_filename('Select patch', (('Patches', suffixes),))
|
||||
except Exception as e:
|
||||
messagebox('Error', str(e), error=True)
|
||||
else:
|
||||
file, _, component = identify(filename)
|
||||
if file and component:
|
||||
launch([*get_exe(component), file], component.cli)
|
||||
|
||||
|
||||
def browse_files():
|
||||
file = user_path()
|
||||
if is_linux:
|
||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
subprocess.Popen([exe, file])
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, file])
|
||||
else:
|
||||
import webbrowser
|
||||
webbrowser.open(file)
|
||||
|
||||
|
||||
# noinspection PyArgumentList
|
||||
class Type(Enum):
|
||||
TOOL = auto()
|
||||
FUNC = auto() # not a real component
|
||||
CLIENT = auto()
|
||||
ADJUSTER = auto()
|
||||
|
||||
|
||||
class SuffixIdentifier:
|
||||
suffixes: Iterable[str]
|
||||
|
||||
def __init__(self, *args: str):
|
||||
self.suffixes = args
|
||||
|
||||
def __call__(self, path: str):
|
||||
if isinstance(path, str):
|
||||
for suffix in self.suffixes:
|
||||
if path.endswith(suffix):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Component:
|
||||
display_name: str
|
||||
type: Optional[Type]
|
||||
script_name: Optional[str]
|
||||
frozen_name: Optional[str]
|
||||
icon: str # just the name, no suffix
|
||||
cli: bool
|
||||
func: Optional[Callable]
|
||||
file_identifier: Optional[Callable[[str], bool]]
|
||||
|
||||
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
|
||||
cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None,
|
||||
file_identifier: Optional[Callable[[str], bool]] = None):
|
||||
self.display_name = display_name
|
||||
self.script_name = script_name
|
||||
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
|
||||
self.icon = icon
|
||||
self.cli = cli
|
||||
self.type = component_type or \
|
||||
None if not display_name else \
|
||||
Type.FUNC if func else \
|
||||
Type.CLIENT if 'Client' in display_name else \
|
||||
Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL
|
||||
self.func = func
|
||||
self.file_identifier = file_identifier
|
||||
|
||||
def handles_file(self, path: str):
|
||||
return self.file_identifier(path) if self.file_identifier else False
|
||||
|
||||
|
||||
components: Iterable[Component] = (
|
||||
# Launcher
|
||||
Component('', 'Launcher'),
|
||||
# Core
|
||||
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
|
||||
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
|
||||
Component('Generate', 'Generate', cli=True),
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
||||
# SNI
|
||||
Component('SNI Client', 'SNIClient',
|
||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3',
|
||||
'.apsmw', '.apl2ac')),
|
||||
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'),
|
||||
# Pokémon
|
||||
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
|
||||
# TLoZ
|
||||
Component('Zelda 1 Client', 'Zelda1Client'),
|
||||
# ChecksFinder
|
||||
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
||||
# Starcraft 2
|
||||
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
||||
# Wargroove
|
||||
Component('Wargroove Client', 'WargrooveClient'),
|
||||
# Zillion
|
||||
Component('Zillion Client', 'ZillionClient',
|
||||
file_identifier=SuffixIdentifier('.apzl')),
|
||||
# Functions
|
||||
Component('Open host.yaml', func=open_host_yaml),
|
||||
Component('Open Patch', func=open_patch),
|
||||
Component('Browse Files', func=browse_files),
|
||||
)
|
||||
icon_paths = {
|
||||
'icon': local_path('data', 'icon.ico' if is_windows else 'icon.png'),
|
||||
'mcicon': local_path('data', 'mcicon.ico')
|
||||
}
|
||||
|
||||
|
||||
def identify(path: Union[None, str]):
|
||||
if path is None:
|
||||
return None, None, None
|
||||
for component in components:
|
||||
if component.handles_file(path):
|
||||
return path, component.script_name, component
|
||||
return (None, None, None) if '/' in path or '\\' in path else (None, path, None)
|
||||
|
||||
|
||||
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
||||
if isinstance(component, str):
|
||||
name = component
|
||||
component = None
|
||||
if name.startswith('Archipelago'):
|
||||
name = name[11:]
|
||||
if name.endswith('.exe'):
|
||||
name = name[:-4]
|
||||
if name.endswith('.py'):
|
||||
name = name[:-3]
|
||||
if not name:
|
||||
return None
|
||||
for c in components:
|
||||
if c.script_name == name or c.frozen_name == f'Archipelago{name}':
|
||||
component = c
|
||||
break
|
||||
if not component:
|
||||
return None
|
||||
if is_frozen():
|
||||
suffix = '.exe' if is_windows else ''
|
||||
return [local_path(f'{component.frozen_name}{suffix}')]
|
||||
else:
|
||||
return [sys.executable, local_path(f'{component.script_name}.py')]
|
||||
|
||||
|
||||
def launch(exe, in_terminal=False):
|
||||
if in_terminal:
|
||||
if is_windows:
|
||||
subprocess.Popen(['start', *exe], shell=True)
|
||||
return
|
||||
elif is_linux:
|
||||
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
||||
if terminal:
|
||||
subprocess.Popen([terminal, '-e', shlex.join(exe)])
|
||||
return
|
||||
elif is_macos:
|
||||
terminal = [which('open'), '-W', '-a', 'Terminal.app']
|
||||
subprocess.Popen([*terminal, *exe])
|
||||
return
|
||||
subprocess.Popen(exe)
|
||||
|
||||
|
||||
def run_gui():
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label
|
||||
|
||||
class Launcher(App):
|
||||
base_title: str = "Archipelago Launcher"
|
||||
container: ContainerLayout
|
||||
grid: GridLayout
|
||||
|
||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL and isfile(get_exe(c)[-1])}
|
||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT and isfile(get_exe(c)[-1])}
|
||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER and isfile(get_exe(c)[-1])}
|
||||
_funcs = {c.display_name: c for c in components if c.type == Type.FUNC}
|
||||
|
||||
def __init__(self, ctx=None):
|
||||
self.title = self.base_title
|
||||
self.ctx = ctx
|
||||
self.icon = r"data/icon.png"
|
||||
super().__init__()
|
||||
|
||||
def build(self):
|
||||
self.container = ContainerLayout()
|
||||
self.grid = GridLayout(cols=2)
|
||||
self.container.add_widget(self.grid)
|
||||
|
||||
button_layout = self.grid # make buttons fill the window
|
||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||
self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()):
|
||||
# column 1
|
||||
if tool:
|
||||
button = Button(text=tool[0])
|
||||
button.component = tool[1]
|
||||
button.bind(on_release=self.component_action)
|
||||
button_layout.add_widget(button)
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
# column 2
|
||||
if client:
|
||||
button = Button(text=client[0])
|
||||
button.component = client[1]
|
||||
button.bind(on_press=self.component_action)
|
||||
button_layout.add_widget(button)
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
|
||||
return self.container
|
||||
|
||||
@staticmethod
|
||||
def component_action(button):
|
||||
if button.component.type == Type.FUNC:
|
||||
button.component.func()
|
||||
else:
|
||||
launch(get_exe(button.component), button.component.cli)
|
||||
|
||||
Launcher().run()
|
||||
|
||||
|
||||
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
if isinstance(args, argparse.Namespace):
|
||||
args = {k: v for k, v in args._get_kwargs()}
|
||||
elif not args:
|
||||
args = {}
|
||||
|
||||
if "Patch|Game|Component" in args:
|
||||
file, component, _ = identify(args["Patch|Game|Component"])
|
||||
if file:
|
||||
args['file'] = file
|
||||
if component:
|
||||
args['component'] = component
|
||||
|
||||
if 'file' in args:
|
||||
subprocess.run([*get_exe(args['component']), args['file'], *args['args']])
|
||||
elif 'component' in args:
|
||||
subprocess.run([*get_exe(args['component']), *args['args']])
|
||||
else:
|
||||
run_gui()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_logging('Launcher')
|
||||
parser = argparse.ArgumentParser(description='Archipelago Launcher')
|
||||
parser.add_argument('Patch|Game|Component', type=str, nargs='?',
|
||||
help="Pass either a patch file, a generated game or the name of a component to run.")
|
||||
parser.add_argument('args', nargs="*", help="Arguments to pass to component.")
|
||||
main(parser.parse_args())
|
||||
107
LttPAdjuster.py
@@ -20,15 +20,22 @@ from tkinter.constants import DISABLED, NORMAL
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||
from Utils import output_path, local_path, open_file, get_cert_none_ssl_context, persistent_store, get_adjuster_settings, tkinter_center_window
|
||||
from Patch import GAME_ALTTP
|
||||
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
|
||||
|
||||
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
|
||||
|
||||
class AdjusterWorld(object):
|
||||
def __init__(self, sprite_pool):
|
||||
import random
|
||||
self.sprite_pool = {1: sprite_pool}
|
||||
self.slot_seeds = {1: random}
|
||||
self.per_slot_randoms = {1: random}
|
||||
|
||||
|
||||
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
@@ -40,9 +47,9 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
|
||||
|
||||
parser.add_argument('--rom', default='ER_base.sfc', help='Path to an ALttP rom to adjust.')
|
||||
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
|
||||
parser.add_argument('--baserom', default='Zelda no Densetsu - Kamigami no Triforce (Japan).sfc',
|
||||
help='Path to an ALttP JAP(1.0) rom to use as a base.')
|
||||
help='Path to an ALttP Japan(1.0) rom to use as a base.')
|
||||
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
|
||||
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
|
||||
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
|
||||
@@ -53,6 +60,7 @@ def main():
|
||||
''')
|
||||
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
|
||||
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
|
||||
parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true')
|
||||
parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
|
||||
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
|
||||
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
|
||||
@@ -77,9 +85,9 @@ def main():
|
||||
parser.add_argument('--ow_palettes', default='default',
|
||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||
'sick'])
|
||||
parser.add_argument('--link_palettes', default='default',
|
||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||
'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',
|
||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||
'sick'])
|
||||
@@ -127,10 +135,13 @@ def main():
|
||||
|
||||
def adjust(args):
|
||||
start = time.perf_counter()
|
||||
init_logging("LttP Adjuster")
|
||||
logger = logging.getLogger('Adjuster')
|
||||
logger.info('Patching ROM.')
|
||||
vanillaRom = args.baserom
|
||||
if os.path.splitext(args.rom)[-1].lower() == '.apbp':
|
||||
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
||||
vanillaRom = local_path(vanillaRom)
|
||||
if os.path.splitext(args.rom)[-1].lower() == '.aplttp':
|
||||
import Patch
|
||||
meta, args.rom = Patch.create_rom_file(args.rom)
|
||||
|
||||
@@ -155,7 +166,7 @@ def adjust(args):
|
||||
|
||||
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
|
||||
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
|
||||
deathlink=args.deathlink)
|
||||
deathlink=args.deathlink, allowcollect=args.allowcollect)
|
||||
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
|
||||
rom.write_to_file(path)
|
||||
|
||||
@@ -186,7 +197,7 @@ def adjustGUI():
|
||||
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
|
||||
|
||||
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)
|
||||
|
||||
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
|
||||
@@ -210,6 +221,7 @@ def adjustGUI():
|
||||
guiargs.music = bool(rom_vars.MusicVar.get())
|
||||
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
|
||||
guiargs.deathlink = bool(rom_vars.DeathLinkVar.get())
|
||||
guiargs.allowcollect = bool(rom_vars.AllowCollectVar.get())
|
||||
guiargs.rom = romVar2.get()
|
||||
guiargs.baserom = romVar.get()
|
||||
guiargs.sprite = rom_vars.sprite
|
||||
@@ -246,6 +258,7 @@ def adjustGUI():
|
||||
guiargs.music = bool(rom_vars.MusicVar.get())
|
||||
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
|
||||
guiargs.deathlink = bool(rom_vars.DeathLinkVar.get())
|
||||
guiargs.allowcollect = bool(rom_vars.AllowCollectVar.get())
|
||||
guiargs.baserom = romVar.get()
|
||||
if isinstance(rom_vars.sprite, Sprite):
|
||||
guiargs.sprite = rom_vars.sprite.name
|
||||
@@ -278,7 +291,7 @@ def run_sprite_update():
|
||||
else:
|
||||
top.withdraw()
|
||||
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
||||
while not done.isSet():
|
||||
while not done.is_set():
|
||||
task.do_events()
|
||||
logging.info("Done updating sprites")
|
||||
|
||||
@@ -286,9 +299,10 @@ def run_sprite_update():
|
||||
def update_sprites(task, on_finish=None):
|
||||
resultmessage = ""
|
||||
successful = True
|
||||
sprite_dir = local_path("data", "sprites", "alttpr")
|
||||
sprite_dir = user_path("data", "sprites", "alttpr")
|
||||
os.makedirs(sprite_dir, exist_ok=True)
|
||||
ctx = get_cert_none_ssl_context()
|
||||
|
||||
def finished():
|
||||
task.close_window()
|
||||
if on_finish:
|
||||
@@ -502,24 +516,29 @@ def get_rom_frame(parent=None):
|
||||
|
||||
def get_rom_options_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
defaults = {
|
||||
"auto_apply": 'ask',
|
||||
"music": True,
|
||||
"reduceflashing": True,
|
||||
"deathlink": False,
|
||||
"sprite": None,
|
||||
"quickswap": True,
|
||||
"menuspeed": 'normal',
|
||||
"heartcolor": 'red',
|
||||
"heartbeep": 'normal',
|
||||
"ow_palettes": 'default',
|
||||
"uw_palettes": 'default',
|
||||
"hud_palettes": 'default',
|
||||
"sword_palettes": 'default',
|
||||
"shield_palettes": 'default',
|
||||
"sprite_pool": [],
|
||||
"allowcollect": False,
|
||||
}
|
||||
if not adjuster_settings:
|
||||
adjuster_settings = Namespace()
|
||||
adjuster_settings.auto_apply = 'ask'
|
||||
adjuster_settings.music = True
|
||||
adjuster_settings.reduceflashing = True
|
||||
adjuster_settings.deathlink = False
|
||||
adjuster_settings.sprite = None
|
||||
adjuster_settings.quickswap = True
|
||||
adjuster_settings.menuspeed = 'normal'
|
||||
adjuster_settings.heartcolor = 'red'
|
||||
adjuster_settings.heartbeep = 'normal'
|
||||
adjuster_settings.ow_palettes = 'default'
|
||||
adjuster_settings.uw_palettes = 'default'
|
||||
adjuster_settings.hud_palettes = 'default'
|
||||
adjuster_settings.sword_palettes = 'default'
|
||||
adjuster_settings.shield_palettes = 'default'
|
||||
if not hasattr(adjuster_settings, 'sprite_pool'):
|
||||
adjuster_settings.sprite_pool = []
|
||||
for key, defaultvalue in defaults.items():
|
||||
if not hasattr(adjuster_settings, key):
|
||||
setattr(adjuster_settings, key, defaultvalue)
|
||||
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options")
|
||||
romOptionsFrame.columnconfigure(0, weight=1)
|
||||
@@ -542,6 +561,10 @@ def get_rom_options_frame(parent=None):
|
||||
DeathLinkCheckbutton = Checkbutton(romOptionsFrame, text="DeathLink (Team Deaths)", variable=vars.DeathLinkVar)
|
||||
DeathLinkCheckbutton.grid(row=7, column=0, sticky=W)
|
||||
|
||||
vars.AllowCollectVar = IntVar(value=adjuster_settings.allowcollect)
|
||||
AllowCollectCheckbutton = Checkbutton(romOptionsFrame, text="Allow Collect", variable=vars.AllowCollectVar)
|
||||
AllowCollectCheckbutton.grid(row=8, column=0, sticky=W)
|
||||
|
||||
spriteDialogFrame = Frame(romOptionsFrame)
|
||||
spriteDialogFrame.grid(row=0, column=1)
|
||||
baseSpriteLabel = Label(spriteDialogFrame, text='Sprite:')
|
||||
@@ -703,8 +726,8 @@ def get_rom_options_frame(parent=None):
|
||||
|
||||
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
|
||||
autoApplyFrame = Frame(romOptionsFrame)
|
||||
autoApplyFrame.grid(row=8, column=0, columnspan=2, sticky=W)
|
||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
|
||||
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .aplttp files")
|
||||
filler.pack(side=TOP, expand=True, fill=X)
|
||||
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
||||
askRadio.pack(side=LEFT, padx=5, pady=5)
|
||||
@@ -731,6 +754,7 @@ class SpriteSelector():
|
||||
self.window['pady'] = 5
|
||||
self.spritesPerRow = 32
|
||||
self.all_sprites = []
|
||||
self.invalid_sprites = []
|
||||
self.sprite_pool = spritePool
|
||||
|
||||
def open_custom_sprite_dir(_evt):
|
||||
@@ -812,6 +836,13 @@ class SpriteSelector():
|
||||
self.window.focus()
|
||||
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):
|
||||
self.callback(("remove", spritename))
|
||||
self.spritePoolButtons.buttons.remove(button)
|
||||
@@ -876,7 +907,13 @@ class SpriteSelector():
|
||||
sprites = []
|
||||
|
||||
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())
|
||||
|
||||
@@ -1013,11 +1050,11 @@ class SpriteSelector():
|
||||
|
||||
@property
|
||||
def alttpr_sprite_dir(self):
|
||||
return local_path("data", "sprites", "alttpr")
|
||||
return user_path("data", "sprites", "alttpr")
|
||||
|
||||
@property
|
||||
def custom_sprite_dir(self):
|
||||
return local_path("data", "sprites", "custom")
|
||||
return user_path("data", "sprites", "custom")
|
||||
|
||||
|
||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||
@@ -1243,4 +1280,4 @@ class ToolTips(object):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
main()
|
||||
|
||||
409
Main.py
@@ -1,6 +1,4 @@
|
||||
import copy
|
||||
import collections
|
||||
from itertools import zip_longest, chain
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
@@ -9,13 +7,14 @@ import concurrent.futures
|
||||
import pickle
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import Dict, Tuple, Optional, Set
|
||||
from typing import Dict, List, Tuple, Optional, Set
|
||||
|
||||
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
||||
from worlds.alttp.Items import item_name_groups
|
||||
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
|
||||
from BaseClasses import Item, MultiWorld, CollectionState, Region, LocationProgressType, Location
|
||||
import worlds
|
||||
from worlds.alttp.SubClasses import LTTPRegionType
|
||||
from worlds.alttp.Regions import is_main_entrance
|
||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||
from worlds.alttp.Shops import FillDisabledShopSlots
|
||||
from Utils import output_path, get_options, __version__, version_tuple
|
||||
from worlds.generic.Rules import locality_rules, exclusion_rules
|
||||
from worlds import AutoWorld
|
||||
@@ -39,7 +38,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
world = MultiWorld(args.multi)
|
||||
|
||||
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.logic = args.logic.copy()
|
||||
@@ -48,13 +48,11 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
world.item_functionality = args.item_functionality.copy()
|
||||
world.timer = args.timer.copy()
|
||||
world.goal = args.goal.copy()
|
||||
world.open_pyramid = args.open_pyramid.copy()
|
||||
world.boss_shuffle = args.shufflebosses.copy()
|
||||
world.enemy_health = args.enemy_health.copy()
|
||||
world.enemy_damage = args.enemy_damage.copy()
|
||||
world.beemizer_total_chance = args.beemizer_total_chance.copy()
|
||||
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
|
||||
world.timer = args.timer.copy()
|
||||
world.countdown_start_time = args.countdown_start_time.copy()
|
||||
world.red_clock_time = args.red_clock_time.copy()
|
||||
world.blue_clock_time = args.blue_clock_time.copy()
|
||||
@@ -72,7 +70,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
world.required_medallions = args.required_medallions.copy()
|
||||
world.game = args.game.copy()
|
||||
world.player_name = args.name.copy()
|
||||
world.enemizer = args.enemizercli
|
||||
world.sprite = args.sprite.copy()
|
||||
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
||||
|
||||
@@ -81,17 +78,34 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
world.state = CollectionState(world)
|
||||
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)
|
||||
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():
|
||||
if not cls.hidden:
|
||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} Items | "
|
||||
f"{len(cls.location_names):3} Locations")
|
||||
logger.info(f" Item IDs: {min(cls.item_id_to_name):{numlength}} - "
|
||||
f"{max(cls.item_id_to_name):{numlength}} | "
|
||||
f"Location IDs: {min(cls.location_id_to_name):{numlength}} - "
|
||||
f"{max(cls.location_id_to_name):{numlength}}")
|
||||
if not cls.hidden and len(cls.item_names) > 0:
|
||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
|
||||
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
|
||||
f"{max(cls.item_id_to_name):{item_digits}}) | "
|
||||
f"{len(cls.location_names):{location_count}} "
|
||||
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
|
||||
f"{max(cls.location_id_to_name):{location_digits}})")
|
||||
|
||||
del item_digits, location_digits, item_count, location_count
|
||||
|
||||
AutoWorld.call_stage(world, "assert_generate")
|
||||
|
||||
AutoWorld.call_all(world, "generate_early")
|
||||
|
||||
@@ -102,29 +116,25 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
for _ in range(count):
|
||||
world.push_precollected(world.create_item(item_name, player))
|
||||
|
||||
for player in world.player_ids:
|
||||
if player in world.get_game_players("A Link to the Past"):
|
||||
# enforce pre-defined local items.
|
||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||
world.local_items[player].value.add('Triforce Piece')
|
||||
|
||||
# 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.')
|
||||
AutoWorld.call_all(world, "create_regions")
|
||||
|
||||
logger.info('Creating 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.')
|
||||
|
||||
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:
|
||||
for player in world.player_ids:
|
||||
locality_rules(world, player)
|
||||
locality_rules(world)
|
||||
else:
|
||||
world.non_local_items[1].value = set()
|
||||
world.local_items[1].value = set()
|
||||
@@ -141,17 +151,23 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
# temporary home for item links, should be moved out of Main
|
||||
for group_id, group in world.groups.items():
|
||||
# TODO: remove when LttP options are transitioned over
|
||||
world.difficulty_requirements[group_id] = world.difficulty_requirements[next(iter(group["players"]))]
|
||||
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]):
|
||||
advancement = set()
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
||||
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}
|
||||
for item in world.itempool:
|
||||
if item.player in counters and item.name in shared_pool:
|
||||
counters[item.player][item.name] += 1
|
||||
if item.advancement:
|
||||
advancement.add(item.name)
|
||||
classifications[item.name] |= item.classification
|
||||
|
||||
for player in players.copy():
|
||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||
players.remove(player)
|
||||
del (counters[player])
|
||||
|
||||
if not players:
|
||||
return None, None
|
||||
|
||||
for item in shared_pool:
|
||||
count = min(counters[player][item] for player in players)
|
||||
@@ -160,23 +176,22 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
counters[player][item] = count
|
||||
else:
|
||||
for player in players:
|
||||
del(counters[player][item])
|
||||
return counters, advancement
|
||||
del (counters[player][item])
|
||||
return counters, classifications
|
||||
|
||||
common_item_count, common_advancement_items = find_common_pool(group["players"], group["item_pool"])
|
||||
# TODO: fix logic
|
||||
if common_advancement_items:
|
||||
logger.warning(f"Logical requirements for {', '.join(common_advancement_items)} in group {group['name']} "
|
||||
f"will be incorrect.")
|
||||
new_itempool = []
|
||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||
if not common_item_count:
|
||||
continue
|
||||
|
||||
new_itempool: List[Item] = []
|
||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||
advancement = item_name in common_advancement_items
|
||||
for _ in range(item_count):
|
||||
new_item = group["world"].create_item(item_name)
|
||||
new_item.advancement = advancement
|
||||
# mangle together all original classification bits
|
||||
new_item.classification |= classifications[item_name]
|
||||
new_itempool.append(new_item)
|
||||
|
||||
region = Region("Menu", RegionType.Generic, "ItemLink", group_id, world)
|
||||
region = Region("Menu", group_id, world, "ItemLink")
|
||||
world.regions.append(region)
|
||||
locations = region.locations = []
|
||||
for item in world.itempool:
|
||||
@@ -184,7 +199,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
if count:
|
||||
loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}",
|
||||
None, region)
|
||||
loc.access_rule = lambda state: state.has(item.name, group_id, count)
|
||||
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
|
||||
state.has(item_name, group_id_, count_)
|
||||
|
||||
locations.append(loc)
|
||||
loc.place_locked_item(item)
|
||||
common_item_count[item.player][item.name] -= 1
|
||||
@@ -194,23 +211,27 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
itemcount = len(world.itempool)
|
||||
world.itempool = new_itempool
|
||||
|
||||
# can produce more items than were removed
|
||||
while itemcount > len(world.itempool):
|
||||
items_to_add = []
|
||||
for player in group["players"]:
|
||||
if group["link_replacement"]:
|
||||
item_player = group_id
|
||||
else:
|
||||
item_player = player
|
||||
if group["replacement_items"][player]:
|
||||
world.itempool.append(AutoWorld.call_single(world, "create_item", player,
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_item", item_player,
|
||||
group["replacement_items"][player]))
|
||||
else:
|
||||
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.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
|
||||
|
||||
if any(world.item_links.values()):
|
||||
world._recache()
|
||||
world._all_state = None
|
||||
|
||||
logger.info("Running Item Plando")
|
||||
|
||||
for item in world.itempool:
|
||||
item.world = world
|
||||
|
||||
distribute_planned(world)
|
||||
|
||||
logger.info('Running Pre Main Fill.')
|
||||
@@ -230,6 +251,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
balance_multiworld_progression(world)
|
||||
|
||||
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
|
||||
|
||||
output = tempfile.TemporaryDirectory()
|
||||
@@ -244,24 +269,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
output_file_futures.append(
|
||||
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
|
||||
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
|
||||
world.shuffle[player] != "vanilla" or world.retro[player]}
|
||||
|
||||
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
|
||||
er_hint_data: Dict[int, Dict[int, str]] = {}
|
||||
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
|
||||
|
||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||
for player in range(1, world.players + 1)}
|
||||
@@ -271,44 +281,24 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
for location in world.get_filled_locations():
|
||||
if type(location.address) is int:
|
||||
main_entrance = get_entrance_to_region(location.parent_region)
|
||||
if location.game != "A Link to the Past":
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif location.parent_region.dungeon:
|
||||
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
||||
'Inverted Ganons Tower': 'Ganons Tower'} \
|
||||
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||
checks_in_area[location.player][dungeonname].append(location.address)
|
||||
elif location.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif location.parent_region.type == RegionType.DarkWorld:
|
||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||
checks_in_area[location.player]["Total"] += 1
|
||||
|
||||
oldmancaves = []
|
||||
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
|
||||
for index, take_any in enumerate(takeanyregions):
|
||||
for region in [world.get_region(take_any, player) for player in
|
||||
world.get_game_players("A Link to the Past") if world.retro[player]]:
|
||||
item = world.create_item(
|
||||
region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
|
||||
region.player)
|
||||
player = region.player
|
||||
location_id = SHOP_ID_START + total_shop_slots + index
|
||||
|
||||
main_entrance = get_entrance_to_region(region)
|
||||
if main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[player]["Light World"].append(location_id)
|
||||
else:
|
||||
checks_in_area[player]["Dark World"].append(location_id)
|
||||
checks_in_area[player]["Total"] += 1
|
||||
|
||||
er_hint_data[player][location_id] = main_entrance.name
|
||||
oldmancaves.append(((location_id, player), (item.code, player)))
|
||||
main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance)
|
||||
if 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 == 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)
|
||||
|
||||
@@ -317,11 +307,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
games = {}
|
||||
minimum_versions = {"server": (0, 2, 4), "clients": client_versions}
|
||||
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
|
||||
slot_info = {}
|
||||
names = [[name for player, name in sorted(world.player_name.items())]]
|
||||
for slot in world.player_ids:
|
||||
client_versions[slot] = world.worlds[slot].get_required_client_version()
|
||||
player_world: AutoWorld.World = world.worlds[slot]
|
||||
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
|
||||
client_versions[slot] = player_world.required_client_version
|
||||
games[slot] = world.game[slot]
|
||||
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
|
||||
world.player_types[slot])
|
||||
@@ -329,47 +321,53 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
games[slot] = world.game[slot]
|
||||
slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot],
|
||||
group_members=sorted(group["players"]))
|
||||
precollected_items = {player: [item.code for item in world_precollected]
|
||||
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
||||
for player, world_precollected in world.precollected_items.items()}
|
||||
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
|
||||
|
||||
sending_visible_players = set()
|
||||
|
||||
for slot in world.player_ids:
|
||||
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||
if world.worlds[slot].sending_visible:
|
||||
sending_visible_players.add(slot)
|
||||
|
||||
def precollect_hint(location):
|
||||
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||
location.item.code, False, "", location.item.flags)
|
||||
location.item.code, False, entrance, location.item.flags)
|
||||
precollected_hints[location.player].add(hint)
|
||||
precollected_hints[location.item.player].add(hint)
|
||||
if location.item.player not in world.groups:
|
||||
precollected_hints[location.item.player].add(hint)
|
||||
else:
|
||||
for player in world.groups[location.item.player]["players"]:
|
||||
precollected_hints[player].add(hint)
|
||||
|
||||
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids}
|
||||
for location in world.get_filled_locations():
|
||||
if type(location.address) == int:
|
||||
# item code None should be event, location.address should then also be None
|
||||
assert location.item.code is not None
|
||||
assert location.item.code is not None, "item code None should be event, " \
|
||||
"location.address should then also be None. Location: " \
|
||||
f" {location}"
|
||||
locations_data[location.player][location.address] = \
|
||||
location.item.code, location.item.player, location.item.flags
|
||||
if location.player in sending_visible_players:
|
||||
precollect_hint(location)
|
||||
elif location.name in world.start_location_hints[location.player]:
|
||||
if location.name in world.start_location_hints[location.player]:
|
||||
precollect_hint(location)
|
||||
elif location.item.name in world.start_hints[location.item.player]:
|
||||
precollect_hint(location)
|
||||
elif any([location.item.name in world.start_hints[player]
|
||||
for player in world.groups.get(location.item.player, {}).get("players", [])]):
|
||||
precollect_hint(location)
|
||||
|
||||
# custom datapackage
|
||||
datapackage = {}
|
||||
for game_world in world.worlds.values():
|
||||
if game_world.data_version == 0 and game_world.game not in datapackage:
|
||||
datapackage[game_world.game] = worlds.network_data_package["games"][game_world.game]
|
||||
datapackage[game_world.game]["item_name_groups"] = game_world.item_name_groups
|
||||
datapackage[game_world.game]["location_name_groups"] = game_world.location_name_groups
|
||||
|
||||
multidata = {
|
||||
"slot_data": slot_data,
|
||||
"slot_info": slot_info,
|
||||
"names": names, # TODO: remove around 0.2.5 in favor of slot_info
|
||||
"games": games, # TODO: remove around 0.2.5 in favor of slot_info
|
||||
"names": names, # TODO: remove after 0.3.9
|
||||
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
|
||||
"remote_items": {player for player in world.player_ids if
|
||||
world.worlds[player].remote_items},
|
||||
"remote_start_inventory": {player for player in world.player_ids if
|
||||
world.worlds[player].remote_start_inventory},
|
||||
"locations": locations_data,
|
||||
"checks_in_area": checks_in_area,
|
||||
"server_options": baked_server_options,
|
||||
@@ -379,7 +377,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
"version": tuple(version_tuple),
|
||||
"tags": ["AP"],
|
||||
"minimum_versions": minimum_versions,
|
||||
"seed_name": world.seed_name
|
||||
"seed_name": world.seed_name,
|
||||
"datapackage": datapackage,
|
||||
}
|
||||
AutoWorld.call_all(world, "modify_multidata", multidata)
|
||||
|
||||
@@ -405,13 +404,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
if args.spoiler > 1:
|
||||
logger.info('Calculating playthrough.')
|
||||
create_playthrough(world)
|
||||
world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
||||
|
||||
if args.spoiler:
|
||||
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
||||
|
||||
zipfilename = output_path(f"AP_{world.seed_name}.zip")
|
||||
logger.info(f'Creating final archive at {zipfilename}.')
|
||||
logger.info(f"Creating final archive at {zipfilename}")
|
||||
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9) as zf:
|
||||
for file in os.scandir(temp_dir):
|
||||
@@ -419,143 +418,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)
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import argparse
|
||||
import os, sys
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import atexit
|
||||
import shutil
|
||||
from subprocess import Popen
|
||||
from shutil import copyfile
|
||||
from time import strftime
|
||||
@@ -10,12 +13,12 @@ import logging
|
||||
import requests
|
||||
|
||||
import Utils
|
||||
from Utils import is_windows
|
||||
|
||||
atexit.register(input, "Press enter to exit.")
|
||||
|
||||
# 1 or more digits followed by m or g, then optional b
|
||||
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
|
||||
forge_version = "1.17.1-37.1.1"
|
||||
|
||||
|
||||
def prompt_yes_no(prompt):
|
||||
@@ -31,8 +34,8 @@ def prompt_yes_no(prompt):
|
||||
print('Please respond with "y" or "n".')
|
||||
|
||||
|
||||
# Create mods folder if needed; find AP randomizer jar; return None if not found.
|
||||
def find_ap_randomizer_jar(forge_dir):
|
||||
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
|
||||
mods_dir = os.path.join(forge_dir, 'mods')
|
||||
if os.path.isdir(mods_dir):
|
||||
for entry in os.scandir(mods_dir):
|
||||
@@ -46,8 +49,8 @@ def find_ap_randomizer_jar(forge_dir):
|
||||
return None
|
||||
|
||||
|
||||
# Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory.
|
||||
def replace_apmc_files(forge_dir, apmc_file):
|
||||
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
|
||||
if apmc_file is None:
|
||||
return
|
||||
apdata_dir = os.path.join(forge_dir, 'APData')
|
||||
@@ -69,27 +72,21 @@ def replace_apmc_files(forge_dir, apmc_file):
|
||||
|
||||
def read_apmc_file(apmc_file):
|
||||
from base64 import b64decode
|
||||
import json
|
||||
|
||||
with open(apmc_file, 'r') as f:
|
||||
data = json.loads(b64decode(f.read()))
|
||||
return data
|
||||
return json.loads(b64decode(f.read()))
|
||||
|
||||
|
||||
# Check mod version, download new mod from GitHub releases page if needed.
|
||||
def update_mod(forge_dir, apmc_file, get_prereleases=False):
|
||||
def update_mod(forge_dir, minecraft_version: str, get_prereleases=False):
|
||||
"""Check mod version, download new mod from GitHub releases page if needed. """
|
||||
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
||||
|
||||
if apmc_file is not None:
|
||||
data = read_apmc_file(apmc_file)
|
||||
minecraft_version = data.get('minecraft_version', '')
|
||||
|
||||
client_releases_endpoint = "https://api.github.com/repos/KonoTyran/Minecraft_AP_Randomizer/releases"
|
||||
resp = requests.get(client_releases_endpoint)
|
||||
if resp.status_code == 200: # OK
|
||||
try:
|
||||
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
|
||||
(apmc_file is None or minecraft_version in release['assets'][0]['name']),
|
||||
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
|
||||
(minecraft_version in release['assets'][0]['name']),
|
||||
resp.json()))
|
||||
if ap_randomizer != latest_release['assets'][0]['name']:
|
||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
||||
@@ -125,8 +122,8 @@ def update_mod(forge_dir, apmc_file, get_prereleases=False):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# Check if the EULA is agreed to, and prompt the user to read and agree if necessary.
|
||||
def check_eula(forge_dir):
|
||||
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
|
||||
eula_path = os.path.join(forge_dir, "eula.txt")
|
||||
if not os.path.isfile(eula_path):
|
||||
# Create eula.txt
|
||||
@@ -149,31 +146,39 @@ def check_eula(forge_dir):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# get the current JDK16
|
||||
def find_jdk_dir() -> str:
|
||||
def find_jdk_dir(version: str) -> str:
|
||||
"""get the specified versions jdk directory"""
|
||||
for entry in os.listdir():
|
||||
if os.path.isdir(entry) and entry.startswith("jdk16"):
|
||||
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
|
||||
return os.path.abspath(entry)
|
||||
|
||||
|
||||
# get the java exe location
|
||||
def find_jdk() -> str:
|
||||
jdk = find_jdk_dir()
|
||||
jdk_exe = os.path.join(jdk, "bin", "java.exe")
|
||||
if os.path.isfile(jdk_exe):
|
||||
def find_jdk(version: str) -> str:
|
||||
"""get the java exe location"""
|
||||
|
||||
if is_windows:
|
||||
jdk = find_jdk_dir(version)
|
||||
jdk_exe = os.path.join(jdk, "bin", "java.exe")
|
||||
if os.path.isfile(jdk_exe):
|
||||
return jdk_exe
|
||||
else:
|
||||
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
|
||||
if not jdk_exe:
|
||||
raise Exception("Could not find Java. Is Java installed on the system?")
|
||||
return jdk_exe
|
||||
|
||||
|
||||
# Download Corretto 16 (Amazon JDK)
|
||||
def download_java():
|
||||
jdk = find_jdk_dir()
|
||||
def download_java(java: str):
|
||||
"""Download Corretto (Amazon JDK)"""
|
||||
|
||||
jdk = find_jdk_dir(java)
|
||||
if jdk is not None:
|
||||
print(f"Removing old JDK...")
|
||||
from shutil import rmtree
|
||||
rmtree(jdk)
|
||||
|
||||
print(f"Downloading Java...")
|
||||
jdk_url = "https://corretto.aws/downloads/latest/amazon-corretto-16-x64-windows-jdk.zip"
|
||||
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
|
||||
resp = requests.get(jdk_url)
|
||||
if resp.status_code == 200: # OK
|
||||
print(f"Extracting...")
|
||||
@@ -188,10 +193,11 @@ def download_java():
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# download and install forge
|
||||
def install_forge(directory: str):
|
||||
jdk = find_jdk()
|
||||
if jdk is not None:
|
||||
def install_forge(directory: str, forge_version: str, java_version: str):
|
||||
"""download and install forge"""
|
||||
|
||||
java_exe = find_jdk(java_version)
|
||||
if java_exe is not None:
|
||||
print(f"Downloading Forge {forge_version}...")
|
||||
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
|
||||
resp = requests.get(forge_url)
|
||||
@@ -202,70 +208,144 @@ def install_forge(directory: str):
|
||||
with open(forge_install_jar, 'wb') as f:
|
||||
f.write(resp.content)
|
||||
print(f"Installing Forge...")
|
||||
argstring = ' '.join([jdk, "-jar", "\"" + forge_install_jar+ "\"", "--installServer", "\"" + directory + "\""])
|
||||
install_process = Popen(argstring)
|
||||
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
|
||||
install_process.wait()
|
||||
os.remove(forge_install_jar)
|
||||
|
||||
|
||||
# Run the Forge server. Return process object
|
||||
def run_forge_server(forge_dir: str, heap_arg):
|
||||
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
|
||||
"""Run the Forge server."""
|
||||
|
||||
java_exe = find_jdk()
|
||||
java_exe = find_jdk(java_version)
|
||||
if not os.path.isfile(java_exe):
|
||||
java_exe = "java" # try to fall back on java in the PATH
|
||||
|
||||
heap_arg = max_heap_re.match(max_heap).group()
|
||||
heap_arg = max_heap_re.match(heap_arg).group()
|
||||
if heap_arg[-1] in ['b', 'B']:
|
||||
heap_arg = heap_arg[:-1]
|
||||
heap_arg = "-Xmx" + heap_arg
|
||||
|
||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, "win_args.txt")
|
||||
win_args = []
|
||||
os_args = "win_args.txt" if is_windows else "unix_args.txt"
|
||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
|
||||
forge_args = []
|
||||
with open(args_file) as argfile:
|
||||
for line in argfile:
|
||||
win_args.append(line.strip())
|
||||
forge_args.extend(line.strip().split(" "))
|
||||
|
||||
argstring = ' '.join([java_exe, heap_arg] + win_args + ["-nogui"])
|
||||
logging.info(f"Running Forge server: {argstring}")
|
||||
args = [java_exe, heap_arg, *forge_args, "-nogui"]
|
||||
logging.info(f"Running Forge server: {args}")
|
||||
os.chdir(forge_dir)
|
||||
return Popen(argstring)
|
||||
return Popen(args)
|
||||
|
||||
|
||||
def get_minecraft_versions(version, release_channel="release"):
|
||||
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
|
||||
resp = requests.get(version_file_endpoint)
|
||||
local = False
|
||||
if resp.status_code == 200: # OK
|
||||
try:
|
||||
data = resp.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
||||
local = True
|
||||
else:
|
||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
||||
local = True
|
||||
|
||||
if local:
|
||||
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
|
||||
json.dump(data, f)
|
||||
|
||||
try:
|
||||
if version:
|
||||
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
||||
else:
|
||||
return resp.json()[release_channel][0]
|
||||
except StopIteration:
|
||||
logging.error(f"No compatible mod version found for client version {version}.")
|
||||
|
||||
|
||||
def is_correct_forge(forge_dir) -> bool:
|
||||
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
Utils.init_logging("MinecraftClient")
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
|
||||
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
|
||||
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
|
||||
parser.add_argument('--prerelease', default=False, action='store_true',
|
||||
help="Auto-update prerelease versions.")
|
||||
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
|
||||
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
|
||||
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
|
||||
help="Specify release channel to use.")
|
||||
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
|
||||
help="specify java version.")
|
||||
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
|
||||
help="specify forge version. (Minecraft Version-Forge Version)")
|
||||
|
||||
args = parser.parse_args()
|
||||
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
|
||||
|
||||
# Change to executable's working directory
|
||||
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
|
||||
|
||||
|
||||
options = Utils.get_options()
|
||||
forge_dir = options["minecraft_options"]["forge_directory"]
|
||||
channel = args.channel or options["minecraft_options"]["release_channel"]
|
||||
apmc_data = None
|
||||
data_version = None
|
||||
|
||||
if apmc_file is None and not args.install:
|
||||
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
|
||||
|
||||
if apmc_file is not None:
|
||||
apmc_data = read_apmc_file(apmc_file)
|
||||
data_version = apmc_data.get('client_version', '')
|
||||
|
||||
versions = get_minecraft_versions(data_version, channel)
|
||||
|
||||
forge_dir = Utils.user_path(options["minecraft_options"]["forge_directory"])
|
||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
||||
forge_version = args.forge or versions["forge"]
|
||||
java_version = args.java or versions["java"]
|
||||
java_dir = find_jdk_dir(java_version)
|
||||
|
||||
if args.install:
|
||||
print("Installing Java and Minecraft Forge")
|
||||
download_java()
|
||||
install_forge(forge_dir)
|
||||
if is_windows:
|
||||
print("Installing Java")
|
||||
download_java(java_version)
|
||||
if not is_correct_forge(forge_dir):
|
||||
print("Installing Minecraft Forge")
|
||||
install_forge(forge_dir, forge_version, java_version)
|
||||
else:
|
||||
print("Correct Forge version already found, skipping install.")
|
||||
sys.exit(0)
|
||||
|
||||
if apmc_file is not None and not os.path.isfile(apmc_file):
|
||||
raise FileNotFoundError(f"Path {apmc_file} does not exist or could not be accessed.")
|
||||
if not os.path.isdir(forge_dir):
|
||||
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
|
||||
if apmc_data is None:
|
||||
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
|
||||
|
||||
if is_windows:
|
||||
if java_dir is None or not os.path.isdir(java_dir):
|
||||
if prompt_yes_no("Did not find java directory. Download and install java now?"):
|
||||
download_java(java_version)
|
||||
java_dir = find_jdk_dir(java_version)
|
||||
if java_dir is None or not os.path.isdir(java_dir):
|
||||
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
|
||||
|
||||
if not is_correct_forge(forge_dir):
|
||||
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
|
||||
install_forge(forge_dir, forge_version, java_version)
|
||||
if not os.path.isdir(forge_dir):
|
||||
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
|
||||
|
||||
if not max_heap_re.match(max_heap):
|
||||
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
|
||||
|
||||
update_mod(forge_dir, apmc_file, args.prerelease)
|
||||
update_mod(forge_dir, f"MC{forge_version.split('-')[0]}", channel != "release")
|
||||
replace_apmc_files(forge_dir, apmc_file)
|
||||
check_eula(forge_dir)
|
||||
server_process = run_forge_server(forge_dir, max_heap)
|
||||
server_process = run_forge_server(forge_dir, java_version, max_heap)
|
||||
server_process.wait()
|
||||
|
||||
@@ -2,8 +2,10 @@ import os
|
||||
import sys
|
||||
import subprocess
|
||||
import pkg_resources
|
||||
import warnings
|
||||
|
||||
requirements_files = {'requirements.txt'}
|
||||
local_dir = os.path.dirname(__file__)
|
||||
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
|
||||
|
||||
if sys.version_info < (3, 8, 6):
|
||||
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
||||
@@ -11,11 +13,13 @@ if sys.version_info < (3, 8, 6):
|
||||
update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled
|
||||
|
||||
if not update_ran:
|
||||
for entry in os.scandir("worlds"):
|
||||
if entry.is_dir():
|
||||
req_file = os.path.join(entry.path, "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
requirements_files.add(req_file)
|
||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||
# skip .* (hidden / disabled) folders
|
||||
if not entry.name.startswith("."):
|
||||
if entry.is_dir():
|
||||
req_file = os.path.join(entry.path, "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
requirements_files.add(req_file)
|
||||
|
||||
|
||||
def update_command():
|
||||
@@ -36,21 +40,50 @@ def update(yes=False, force=False):
|
||||
path = os.path.join(os.path.dirname(__file__), req_file)
|
||||
with open(path) as requirementsfile:
|
||||
for line in requirementsfile:
|
||||
if line.startswith('https://'):
|
||||
# extract name and version from url
|
||||
wheel = line.split('/')[-1]
|
||||
name, version, _ = wheel.split('-', 2)
|
||||
line = f'{name}=={version}'
|
||||
if not line or line[0] == "#":
|
||||
continue # ignore comments
|
||||
if line.startswith(("https://", "git+https://")):
|
||||
# extract name and version for url
|
||||
rest = line.split('/')[-1]
|
||||
line = ""
|
||||
if "#egg=" in rest:
|
||||
# from egg info
|
||||
rest, egg = rest.split("#egg=", 1)
|
||||
egg = egg.split(";", 1)[0].rstrip()
|
||||
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
|
||||
warnings.warn(f"Specifying version as #egg={egg} will become unavailable in pip 25.0. "
|
||||
"Use name @ url#version instead.", DeprecationWarning)
|
||||
line = egg
|
||||
else:
|
||||
egg = ""
|
||||
if "@" in rest and not line:
|
||||
raise ValueError("Can't deduce version from requirement")
|
||||
elif not line:
|
||||
# from filename
|
||||
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
|
||||
name, version, _ = rest.split("-", 2)
|
||||
line = f'{egg or name}=={version}'
|
||||
elif "@" in line and "#" in line:
|
||||
# PEP 508 does not allow us to specify a version, so we use custom syntax
|
||||
# name @ url#version ; marker
|
||||
name, rest = line.split("@", 1)
|
||||
version = rest.split("#", 1)[1].split(";", 1)[0].rstrip()
|
||||
line = f"{name.rstrip()}=={version}"
|
||||
if ";" in rest: # keep marker
|
||||
line += rest[rest.find(";"):]
|
||||
requirements = pkg_resources.parse_requirements(line)
|
||||
for requirement in requirements:
|
||||
requirement = str(requirement)
|
||||
for requirement in map(str, requirements):
|
||||
try:
|
||||
pkg_resources.require(requirement)
|
||||
except pkg_resources.ResolutionError:
|
||||
if not yes:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
input(f'Requirement {requirement} is not satisfied, press enter to install it')
|
||||
try:
|
||||
input(f"\nRequirement {requirement} is not satisfied, press enter to install it")
|
||||
except KeyboardInterrupt:
|
||||
print("\nAborting")
|
||||
sys.exit(1)
|
||||
update_command()
|
||||
return
|
||||
|
||||
@@ -61,6 +94,9 @@ if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description='Install archipelago requirements')
|
||||
parser.add_argument('-y', '--yes', dest='yes', action='store_true', help='answer "yes" to all questions')
|
||||
parser.add_argument('-f', '--force', dest='force', action='store_true', help='force update')
|
||||
parser.add_argument('-a', '--append', nargs="*", dest='additional_requirements',
|
||||
help='List paths to additional requirement files.')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.additional_requirements:
|
||||
requirements_files.update(args.additional_requirements)
|
||||
update(args.yes, args.force)
|
||||
|
||||
1128
MultiServer.py
25
NetUtils.py
@@ -43,7 +43,7 @@ class Permission(enum.IntFlag):
|
||||
disabled = 0b000 # 0, completely disables access
|
||||
enabled = 0b001 # 1, allows manual use
|
||||
goal = 0b010 # 2, allows manual use after goal completion
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for forfeit
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for release
|
||||
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
|
||||
|
||||
@staticmethod
|
||||
@@ -86,7 +86,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
data = obj._asdict()
|
||||
data["class"] = obj.__class__.__name__
|
||||
return data
|
||||
if isinstance(obj, (tuple, list, set)):
|
||||
if isinstance(obj, (tuple, list, set, frozenset)):
|
||||
return tuple(_scan_for_TypedTuples(o) for o in obj)
|
||||
if isinstance(obj, dict):
|
||||
return {key: _scan_for_TypedTuples(value) for key, value in obj.items()}
|
||||
@@ -96,10 +96,11 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
_encode = JSONEncoder(
|
||||
ensure_ascii=False,
|
||||
check_circular=False,
|
||||
separators=(',', ':'),
|
||||
).encode
|
||||
|
||||
|
||||
def encode(obj):
|
||||
def encode(obj: typing.Any) -> str:
|
||||
return _encode(_scan_for_TypedTuples(obj))
|
||||
|
||||
|
||||
@@ -108,9 +109,11 @@ def get_any_version(data: dict) -> Version:
|
||||
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
|
||||
|
||||
|
||||
whitelist = {"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
}
|
||||
allowlist = {
|
||||
"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
"NetworkSlot": NetworkSlot
|
||||
}
|
||||
|
||||
custom_hooks = {
|
||||
"Version": get_any_version
|
||||
@@ -122,7 +125,7 @@ def _object_hook(o: typing.Any) -> typing.Any:
|
||||
hook = custom_hooks.get(o.get("class", None), None)
|
||||
if hook:
|
||||
return hook(o)
|
||||
cls = whitelist.get(o.get("class", None), None)
|
||||
cls = allowlist.get(o.get("class", None), None)
|
||||
if cls:
|
||||
for key in tuple(o):
|
||||
if key not in cls._fields:
|
||||
@@ -233,7 +236,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
node["color"] = 'cyan'
|
||||
elif flags & 0b001: # advancement
|
||||
node["color"] = 'plum'
|
||||
elif flags & 0b010: # never_exclude
|
||||
elif flags & 0b010: # useful
|
||||
node["color"] = 'slateblue'
|
||||
elif flags & 0b100: # trap
|
||||
node["color"] = 'salmon'
|
||||
@@ -243,7 +246,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
|
||||
def _handle_item_id(self, node: JSONMessagePart):
|
||||
item_id = int(node["text"])
|
||||
node["text"] = self.ctx.item_name_getter(item_id)
|
||||
node["text"] = self.ctx.item_names[item_id]
|
||||
return self._handle_item_name(node)
|
||||
|
||||
def _handle_location_name(self, node: JSONMessagePart):
|
||||
@@ -252,7 +255,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
|
||||
def _handle_location_id(self, node: JSONMessagePart):
|
||||
item_id = int(node["text"])
|
||||
node["text"] = self.ctx.location_name_getter(item_id)
|
||||
node["text"] = self.ctx.location_names[item_id]
|
||||
return self._handle_location_name(node)
|
||||
|
||||
def _handle_entrance_name(self, node: JSONMessagePart):
|
||||
@@ -267,7 +270,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
|
||||
|
||||
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
|
||||
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
|
||||
'blue_bg': 44, 'purple_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
|
||||
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
|
||||
|
||||
|
||||
def color_code(*args):
|
||||
|
||||
@@ -3,6 +3,7 @@ import argparse
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
import zipfile
|
||||
from itertools import chain
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
@@ -196,7 +197,7 @@ def set_icon(window):
|
||||
def adjust(args):
|
||||
# Create a fake world and OOTWorld to use as a base
|
||||
world = MultiWorld(1)
|
||||
world.slot_seeds = {1: random}
|
||||
world.per_slot_randoms = {1: random}
|
||||
ootworld = OOTWorld(world, 1)
|
||||
# Set options in the fake OOTWorld
|
||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||
@@ -217,13 +218,18 @@ def adjust(args):
|
||||
# Load up the ROM
|
||||
rom = Rom(file=args.rom, force_use=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
|
||||
rom = Rom(file=args.vanilla_rom, force_use=True)
|
||||
apz5_file = args.rom
|
||||
base_name = os.path.splitext(apz5_file)[0]
|
||||
# Patch file
|
||||
apply_patch_file(rom, 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:
|
||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
|
||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5, .zpf")
|
||||
# Call patch_cosmetics
|
||||
try:
|
||||
patch_cosmetics(ootworld, rom)
|
||||
|
||||
340
OoTClient.py
Normal file
@@ -0,0 +1,340 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
import zipfile
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
||||
ClientCommandProcessor, logger, get_base_parser
|
||||
import Utils
|
||||
from Utils import async_start
|
||||
from worlds import network_data_package
|
||||
from worlds.oot.Rom import Rom, compress_rom_file
|
||||
from worlds.oot.N64Patch import apply_patch_file
|
||||
from worlds.oot.Utils import data_path
|
||||
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart oot_connector.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure oot_connector.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart oot_connector.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
"""
|
||||
Payload: lua -> client
|
||||
{
|
||||
playerName: string,
|
||||
locations: dict,
|
||||
deathlinkActive: bool,
|
||||
isDead: bool,
|
||||
gameComplete: bool
|
||||
}
|
||||
|
||||
Payload: client -> lua
|
||||
{
|
||||
items: list,
|
||||
playerNames: list,
|
||||
triggerDeath: bool
|
||||
}
|
||||
|
||||
Deathlink logic:
|
||||
"Dead" is true <-> Link is at 0 hp.
|
||||
|
||||
deathlink_pending: we need to kill the player
|
||||
deathlink_sent_this_death: we interacted with the multiworld on this death, waiting to reset with living link
|
||||
|
||||
"""
|
||||
|
||||
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
||||
|
||||
script_version: int = 3
|
||||
|
||||
def get_item_value(ap_id):
|
||||
return ap_id - 66000
|
||||
|
||||
|
||||
class OoTCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_n64(self):
|
||||
"""Check N64 Connection State"""
|
||||
if isinstance(self.ctx, OoTContext):
|
||||
logger.info(f"N64 Status: {self.ctx.n64_status}")
|
||||
|
||||
def _cmd_deathlink(self):
|
||||
"""Toggle deathlink from client. Overrides default setting."""
|
||||
if isinstance(self.ctx, OoTContext):
|
||||
self.ctx.deathlink_client_override = True
|
||||
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
||||
async_start(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
||||
|
||||
|
||||
class OoTContext(CommonContext):
|
||||
command_processor = OoTCommandProcessor
|
||||
items_handling = 0b001 # full local
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.game = 'Ocarina of Time'
|
||||
self.n64_streams: (StreamReader, StreamWriter) = None
|
||||
self.n64_sync_task = None
|
||||
self.n64_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.location_table = {}
|
||||
self.collectible_table = {}
|
||||
self.collectible_override_flags_address = 0
|
||||
self.collectible_offsets = {}
|
||||
self.deathlink_enabled = False
|
||||
self.deathlink_pending = False
|
||||
self.deathlink_sent_this_death = False
|
||||
self.deathlink_client_override = False
|
||||
self.version_warning = False
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(OoTContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to Bizhawk to get player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
self.deathlink_pending = True
|
||||
super().on_deathlink(data)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class OoTManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Ocarina of Time Client"
|
||||
|
||||
self.ui = OoTManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_package(self, cmd, args):
|
||||
if cmd == 'Connected':
|
||||
slot_data = args.get('slot_data', None)
|
||||
if slot_data:
|
||||
self.collectible_override_flags_address = slot_data.get('collectible_override_flags', 0)
|
||||
self.collectible_offsets = slot_data.get('collectible_flag_offsets', {})
|
||||
|
||||
|
||||
def get_payload(ctx: OoTContext):
|
||||
if ctx.deathlink_enabled and ctx.deathlink_pending:
|
||||
trigger_death = True
|
||||
ctx.deathlink_sent_this_death = True
|
||||
else:
|
||||
trigger_death = False
|
||||
|
||||
payload = json.dumps({
|
||||
"items": [get_item_value(item.item) for item in ctx.items_received],
|
||||
"playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
|
||||
"triggerDeath": trigger_death,
|
||||
"collectibleOverrides": ctx.collectible_override_flags_address,
|
||||
"collectibleOffsets": ctx.collectible_offsets
|
||||
})
|
||||
return payload
|
||||
|
||||
|
||||
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
|
||||
# Refuse to do anything if ROM is detected as changed
|
||||
if ctx.auth and payload['playerName'] != ctx.auth:
|
||||
logger.warning("ROM change detected. Disconnecting and reconnecting...")
|
||||
ctx.deathlink_enabled = False
|
||||
ctx.deathlink_client_override = False
|
||||
ctx.finished_game = False
|
||||
ctx.location_table = {}
|
||||
ctx.collectible_table = {}
|
||||
ctx.deathlink_pending = False
|
||||
ctx.deathlink_sent_this_death = False
|
||||
ctx.auth = payload['playerName']
|
||||
await ctx.send_connect()
|
||||
return
|
||||
|
||||
# Turn on deathlink if it is on, and if the client hasn't overriden it
|
||||
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
|
||||
await ctx.update_death_link(True)
|
||||
ctx.deathlink_enabled = True
|
||||
|
||||
# Game completion handling
|
||||
if payload['gameComplete'] and not ctx.finished_game:
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "StatusUpdate",
|
||||
"status": 30
|
||||
}])
|
||||
ctx.finished_game = True
|
||||
|
||||
# Locations handling
|
||||
locations = payload['locations']
|
||||
collectibles = payload['collectibles']
|
||||
|
||||
if ctx.location_table != locations or ctx.collectible_table != collectibles:
|
||||
ctx.location_table = locations
|
||||
ctx.collectible_table = collectibles
|
||||
locs1 = [oot_loc_name_to_id[loc] for loc, b in ctx.location_table.items() if b]
|
||||
locs2 = [int(loc) for loc, b in ctx.collectible_table.items() if b]
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationChecks",
|
||||
"locations": locs1 + locs2
|
||||
}])
|
||||
|
||||
# Deathlink handling
|
||||
if ctx.deathlink_enabled:
|
||||
if payload['isDead']: # link is dead
|
||||
ctx.deathlink_pending = False
|
||||
if not ctx.deathlink_sent_this_death:
|
||||
ctx.deathlink_sent_this_death = True
|
||||
await ctx.send_death()
|
||||
else: # link is alive
|
||||
ctx.deathlink_sent_this_death = False
|
||||
|
||||
|
||||
async def n64_sync_task(ctx: OoTContext):
|
||||
logger.info("Starting n64 connector. Use /n64 for status information.")
|
||||
while not ctx.exit_event.is_set():
|
||||
error_status = None
|
||||
if ctx.n64_streams:
|
||||
(reader, writer) = ctx.n64_streams
|
||||
msg = get_payload(ctx).encode()
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||
data_decoded = json.loads(data.decode())
|
||||
reported_version = data_decoded.get('scriptVersion', 0)
|
||||
if reported_version >= script_version:
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
async_start(parse_payload(data_decoded, ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = data_decoded['playerName']
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
else:
|
||||
if not ctx.version_warning:
|
||||
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}. "
|
||||
"Please update to the latest version. "
|
||||
"Your connection to the Archipelago server will not be accepted.")
|
||||
ctx.version_warning = True
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.n64_streams = None
|
||||
if ctx.n64_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to N64")
|
||||
ctx.n64_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.n64_status = f"Was tentatively connected but error occured: {error_status}"
|
||||
elif error_status:
|
||||
ctx.n64_status = error_status
|
||||
logger.info("Lost connection to N64 and attempting to reconnect. Use /n64 for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to N64")
|
||||
ctx.n64_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28921), timeout=10)
|
||||
ctx.n64_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.n64_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif os.path.isfile(auto_start):
|
||||
subprocess.Popen([auto_start, romfile],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def patch_and_run_game(apz5_file):
|
||||
apz5_file = os.path.abspath(apz5_file)
|
||||
base_name = os.path.splitext(apz5_file)[0]
|
||||
decomp_path = base_name + '-decomp.z64'
|
||||
comp_path = base_name + '.z64'
|
||||
# Load vanilla ROM, patch file, compress ROM
|
||||
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
|
||||
apply_patch_file(rom, apz5_file,
|
||||
sub_file=(os.path.basename(base_name) + '.zpf'
|
||||
if zipfile.is_zipfile(apz5_file)
|
||||
else None))
|
||||
rom.write_to_file(decomp_path)
|
||||
os.chdir(data_path("Compress"))
|
||||
compress_rom_file(decomp_path, comp_path)
|
||||
os.remove(decomp_path)
|
||||
async_start(run_game(comp_path))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Utils.init_logging("OoTClient")
|
||||
|
||||
async def main():
|
||||
multiprocessing.freeze_support()
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('apz5_file', default="", type=str, nargs="?",
|
||||
help='Path to an APZ5 file')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.apz5_file:
|
||||
logger.info("APZ5 file supplied, beginning patching process...")
|
||||
async_start(patch_and_run_game(args.apz5_file))
|
||||
|
||||
ctx = OoTContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
ctx.n64_sync_task = asyncio.create_task(n64_sync_task(ctx), name="N64 Sync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.n64_sync_task:
|
||||
await ctx.n64_sync_task
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
759
Options.py
@@ -1,11 +1,21 @@
|
||||
from __future__ import annotations
|
||||
import abc
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
import math
|
||||
import numbers
|
||||
import typing
|
||||
import random
|
||||
|
||||
from schema import Schema, And, Or
|
||||
from schema import Schema, And, Or, Optional
|
||||
from Utils import get_fuzzy_results
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from BaseClasses import PlandoOptions
|
||||
from worlds.AutoWorld import World
|
||||
|
||||
|
||||
class AssembleOptions(type):
|
||||
class AssembleOptions(abc.ABCMeta):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
options = attrs["options"] = {}
|
||||
name_lookup = attrs["name_lookup"] = {}
|
||||
@@ -16,14 +26,36 @@ class AssembleOptions(type):
|
||||
name_lookup.update(base.name_lookup)
|
||||
new_options = {name[7:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("option_")}
|
||||
if "random" in new_options:
|
||||
raise Exception("Choice option 'random' cannot be manually assigned.")
|
||||
|
||||
assert "random" not in new_options, "Choice option 'random' cannot be manually assigned."
|
||||
assert len(new_options) == len(set(new_options.values())), "same ID cannot be used twice. Try alias?"
|
||||
|
||||
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
||||
options.update(new_options)
|
||||
|
||||
# apply aliases, without name_lookup
|
||||
options.update({name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("alias_")})
|
||||
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("alias_")}
|
||||
|
||||
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
|
||||
|
||||
# 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)
|
||||
|
||||
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__
|
||||
if "schema" in attrs.keys():
|
||||
@@ -36,6 +68,7 @@ class AssembleOptions(type):
|
||||
return ret
|
||||
|
||||
return validate
|
||||
|
||||
attrs["__init__"] = validate_decorator(attrs["__init__"])
|
||||
else:
|
||||
# construct an __init__ that calls parent __init__
|
||||
@@ -52,9 +85,11 @@ class AssembleOptions(type):
|
||||
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class Option(metaclass=AssembleOptions):
|
||||
value: int
|
||||
name_lookup: typing.Dict[int, str]
|
||||
T = typing.TypeVar('T')
|
||||
|
||||
|
||||
class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||
value: T
|
||||
default = 0
|
||||
|
||||
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
|
||||
@@ -64,10 +99,14 @@ class Option(metaclass=AssembleOptions):
|
||||
# can be weighted between selections
|
||||
supports_weighting = True
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.get_current_option_name()})"
|
||||
# filled by AssembleOptions:
|
||||
name_lookup: typing.Dict[T, str]
|
||||
options: typing.Dict[str, int]
|
||||
|
||||
def __hash__(self):
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.current_option_name})"
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.value)
|
||||
|
||||
@property
|
||||
@@ -75,39 +114,244 @@ class Option(metaclass=AssembleOptions):
|
||||
return self.name_lookup[self.value]
|
||||
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value: typing.Any) -> str:
|
||||
def get_option_name(cls, value: T) -> str:
|
||||
if cls.auto_display_name:
|
||||
return cls.name_lookup[value].replace("_", " ").title()
|
||||
else:
|
||||
return cls.name_lookup[value]
|
||||
|
||||
def __int__(self) -> int:
|
||||
def __int__(self) -> T:
|
||||
return self.value
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.value)
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
raise NotImplementedError
|
||||
@abc.abstractmethod
|
||||
def from_any(cls, data: typing.Any) -> Option[T]:
|
||||
...
|
||||
|
||||
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 Toggle(Option):
|
||||
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
|
||||
# `int` is not a `numbers.Integral` according to the official typestubs
|
||||
# (even though isinstance(5, numbers.Integral) == True)
|
||||
# https://github.com/python/typing/issues/272
|
||||
# https://github.com/python/mypy/issues/3186
|
||||
# https://github.com/microsoft/pyright/issues/1575
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value == other.value
|
||||
else:
|
||||
return typing.cast(bool, self.value == other)
|
||||
|
||||
def __lt__(self, other: typing.Union[int, NumericOption]) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value < other.value
|
||||
else:
|
||||
return self.value < other
|
||||
|
||||
def __le__(self, other: typing.Union[int, NumericOption]) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value <= other.value
|
||||
else:
|
||||
return self.value <= other
|
||||
|
||||
def __gt__(self, other: typing.Union[int, NumericOption]) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value > other.value
|
||||
else:
|
||||
return self.value > other
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.value)
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.value
|
||||
|
||||
def __mul__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value * other.value
|
||||
else:
|
||||
return self.value * other
|
||||
|
||||
def __rmul__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return other.value * self.value
|
||||
else:
|
||||
return other * self.value
|
||||
|
||||
def __sub__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value - other.value
|
||||
else:
|
||||
return self.value - other
|
||||
|
||||
def __rsub__(self, left: typing.Any) -> typing.Any:
|
||||
if isinstance(left, NumericOption):
|
||||
return left.value - self.value
|
||||
else:
|
||||
return left - self.value
|
||||
|
||||
def __add__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value + other.value
|
||||
else:
|
||||
return self.value + other
|
||||
|
||||
def __radd__(self, left: typing.Any) -> typing.Any:
|
||||
if isinstance(left, NumericOption):
|
||||
return left.value + self.value
|
||||
else:
|
||||
return left + self.value
|
||||
|
||||
def __truediv__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value / other.value
|
||||
else:
|
||||
return self.value / other
|
||||
|
||||
def __rtruediv__(self, left: typing.Any) -> typing.Any:
|
||||
if isinstance(left, NumericOption):
|
||||
return left.value / self.value
|
||||
else:
|
||||
return left / self.value
|
||||
|
||||
def __abs__(self) -> typing.Any:
|
||||
return abs(self.value)
|
||||
|
||||
def __and__(self, other: typing.Any) -> int:
|
||||
return self.value & int(other)
|
||||
|
||||
def __ceil__(self) -> int:
|
||||
return math.ceil(self.value)
|
||||
|
||||
def __floor__(self) -> int:
|
||||
return math.floor(self.value)
|
||||
|
||||
def __floordiv__(self, other: typing.Any) -> int:
|
||||
return self.value // int(other)
|
||||
|
||||
def __invert__(self) -> int:
|
||||
return ~(self.value)
|
||||
|
||||
def __lshift__(self, other: typing.Any) -> int:
|
||||
return self.value << int(other)
|
||||
|
||||
def __mod__(self, other: typing.Any) -> int:
|
||||
return self.value % int(other)
|
||||
|
||||
def __neg__(self) -> int:
|
||||
return -(self.value)
|
||||
|
||||
def __or__(self, other: typing.Any) -> int:
|
||||
return self.value | int(other)
|
||||
|
||||
def __pos__(self) -> int:
|
||||
return +(self.value)
|
||||
|
||||
def __pow__(self, exponent: numbers.Complex, modulus: typing.Optional[numbers.Integral] = None) -> int:
|
||||
if not (modulus is None):
|
||||
assert isinstance(exponent, numbers.Integral)
|
||||
return pow(self.value, exponent, modulus) # type: ignore
|
||||
return self.value ** exponent # type: ignore
|
||||
|
||||
def __rand__(self, other: typing.Any) -> int:
|
||||
return int(other) & self.value
|
||||
|
||||
def __rfloordiv__(self, other: typing.Any) -> int:
|
||||
return int(other) // self.value
|
||||
|
||||
def __rlshift__(self, other: typing.Any) -> int:
|
||||
return int(other) << self.value
|
||||
|
||||
def __rmod__(self, other: typing.Any) -> int:
|
||||
return int(other) % self.value
|
||||
|
||||
def __ror__(self, other: typing.Any) -> int:
|
||||
return int(other) | self.value
|
||||
|
||||
def __round__(self, ndigits: typing.Optional[int] = None) -> int:
|
||||
return round(self.value, ndigits)
|
||||
|
||||
def __rpow__(self, base: typing.Any) -> typing.Any:
|
||||
return base ** self.value
|
||||
|
||||
def __rrshift__(self, other: typing.Any) -> int:
|
||||
return int(other) >> self.value
|
||||
|
||||
def __rshift__(self, other: typing.Any) -> int:
|
||||
return self.value >> int(other)
|
||||
|
||||
def __rxor__(self, other: typing.Any) -> int:
|
||||
return int(other) ^ self.value
|
||||
|
||||
def __trunc__(self) -> int:
|
||||
return math.trunc(self.value)
|
||||
|
||||
def __xor__(self, other: typing.Any) -> int:
|
||||
return self.value ^ int(other)
|
||||
|
||||
|
||||
class Toggle(NumericOption):
|
||||
option_false = 0
|
||||
option_true = 1
|
||||
default = 0
|
||||
|
||||
def __init__(self, value: int):
|
||||
assert value == 0 or value == 1
|
||||
assert value == 0 or value == 1, "value of Toggle can only be 0 or 1"
|
||||
self.value = value
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> Toggle:
|
||||
if text.lower() in {"off", "0", "false", "none", "null", "no"}:
|
||||
if text == "random":
|
||||
return cls(random.choice(list(cls.name_lookup)))
|
||||
elif text.lower() in {"off", "0", "false", "none", "null", "no"}:
|
||||
return cls(0)
|
||||
else:
|
||||
return cls(1)
|
||||
@@ -117,25 +361,7 @@ class Toggle(Option):
|
||||
if type(data) == str:
|
||||
return cls.from_text(data)
|
||||
else:
|
||||
return cls(data)
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, Toggle):
|
||||
return self.value == other.value
|
||||
else:
|
||||
return self.value == other
|
||||
|
||||
def __gt__(self, other):
|
||||
if isinstance(other, Toggle):
|
||||
return self.value > other.value
|
||||
else:
|
||||
return self.value > other
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.value)
|
||||
|
||||
def __int__(self):
|
||||
return int(self.value)
|
||||
return cls(int(data))
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value):
|
||||
@@ -148,7 +374,7 @@ class DefaultOnToggle(Toggle):
|
||||
default = 1
|
||||
|
||||
|
||||
class Choice(Option):
|
||||
class Choice(NumericOption):
|
||||
auto_display_name = True
|
||||
|
||||
def __init__(self, value: int):
|
||||
@@ -176,10 +402,10 @@ class Choice(Option):
|
||||
if isinstance(other, self.__class__):
|
||||
return other.value == self.value
|
||||
elif isinstance(other, str):
|
||||
assert other in self.options
|
||||
assert other in self.options, f"compared against a str that could never be equal. {self} == {other}"
|
||||
return other == self.current_key
|
||||
elif isinstance(other, int):
|
||||
assert other in self.name_lookup
|
||||
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
|
||||
return other == self.value
|
||||
elif isinstance(other, bool):
|
||||
return other == bool(self.value)
|
||||
@@ -190,10 +416,10 @@ class Choice(Option):
|
||||
if isinstance(other, self.__class__):
|
||||
return other.value != self.value
|
||||
elif isinstance(other, str):
|
||||
assert other in self.options
|
||||
assert other in self.options, f"compared against a str that could never be equal. {self} != {other}"
|
||||
return other != self.current_key
|
||||
elif isinstance(other, int):
|
||||
assert other in self.name_lookup
|
||||
assert other in self.name_lookup, f"compared against am int that could never be equal. {self} != {other}"
|
||||
return other != self.value
|
||||
elif isinstance(other, bool):
|
||||
return other != bool(self.value)
|
||||
@@ -205,7 +431,170 @@ class Choice(Option):
|
||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||
|
||||
|
||||
class Range(Option, int):
|
||||
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):
|
||||
range_start = 0
|
||||
range_end = 1
|
||||
|
||||
@@ -220,57 +609,122 @@ class Range(Option, int):
|
||||
def from_text(cls, text: str) -> Range:
|
||||
text = text.lower()
|
||||
if text.startswith("random"):
|
||||
if text == "random-low":
|
||||
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_start), 0)))
|
||||
elif text == "random-high":
|
||||
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_end), 0)))
|
||||
elif text == "random-middle":
|
||||
return cls(int(round(random.triangular(cls.range_start, cls.range_end), 0)))
|
||||
elif text.startswith("random-range-"):
|
||||
textsplit = text.split("-")
|
||||
try:
|
||||
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
|
||||
random_range.sort()
|
||||
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
|
||||
raise Exception(
|
||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||
if text.startswith("random-range-low"):
|
||||
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[0]))))
|
||||
elif text.startswith("random-range-middle"):
|
||||
return cls(int(round(random.triangular(random_range[0], random_range[1]))))
|
||||
elif text.startswith("random-range-high"):
|
||||
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[1]))))
|
||||
else:
|
||||
return cls(int(round(random.randint(random_range[0], random_range[1]))))
|
||||
else:
|
||||
return cls(random.randint(cls.range_start, cls.range_end))
|
||||
return cls.weighted_range(text)
|
||||
elif text == "default" and hasattr(cls, "default"):
|
||||
return cls.from_any(cls.default)
|
||||
elif text == "high":
|
||||
return cls(cls.range_end)
|
||||
elif text == "low":
|
||||
return cls(cls.range_start)
|
||||
elif cls.range_start == 0 \
|
||||
and hasattr(cls, "default") \
|
||||
and cls.default != 0 \
|
||||
and text in ("true", "false"):
|
||||
# these are the conditions where "true" and "false" make sense
|
||||
if text == "true":
|
||||
return cls.from_any(cls.default)
|
||||
else: # "false"
|
||||
return cls(0)
|
||||
return cls(int(text))
|
||||
|
||||
@classmethod
|
||||
def weighted_range(cls, text) -> Range:
|
||||
if text == "random-low":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
|
||||
elif text == "random-high":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
|
||||
elif text == "random-middle":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end))
|
||||
elif text.startswith("random-range-"):
|
||||
return cls.custom_range(text)
|
||||
elif text == "random":
|
||||
return cls(random.randint(cls.range_start, cls.range_end))
|
||||
else:
|
||||
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
|
||||
f"Acceptable values are: random, random-high, random-middle, random-low, "
|
||||
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
|
||||
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||
|
||||
@classmethod
|
||||
def custom_range(cls, text) -> Range:
|
||||
textsplit = text.split("-")
|
||||
try:
|
||||
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
|
||||
random_range.sort()
|
||||
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
|
||||
raise Exception(
|
||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||
if text.startswith("random-range-low"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
|
||||
elif text.startswith("random-range-middle"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1]))
|
||||
elif text.startswith("random-range-high"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
|
||||
else:
|
||||
return cls(random.randint(random_range[0], random_range[1]))
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any) -> Range:
|
||||
if type(data) == int:
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self, value):
|
||||
@classmethod
|
||||
def get_option_name(cls, value: int) -> str:
|
||||
return str(value)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
@staticmethod
|
||||
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
|
||||
return int(round(random.triangular(lower, end, tri), 0))
|
||||
|
||||
|
||||
class SpecialRange(Range):
|
||||
special_range_cutoff = 0
|
||||
special_range_names: typing.Dict[str, int] = {}
|
||||
"""Special Range names have to be all lowercase as matching is done with text.lower()"""
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> Range:
|
||||
text = text.lower()
|
||||
if text in cls.special_range_names:
|
||||
return cls(cls.special_range_names[text])
|
||||
return super().from_text(text)
|
||||
|
||||
@classmethod
|
||||
def weighted_range(cls, text) -> Range:
|
||||
if text == "random-low":
|
||||
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.special_range_cutoff))
|
||||
elif text == "random-high":
|
||||
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.range_end))
|
||||
elif text == "random-middle":
|
||||
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end))
|
||||
elif text.startswith("random-range-"):
|
||||
return cls.custom_range(text)
|
||||
elif text == "random":
|
||||
return cls(random.randint(cls.special_range_cutoff, cls.range_end))
|
||||
else:
|
||||
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
|
||||
f"Acceptable values are: random, random-high, random-middle, random-low, "
|
||||
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
|
||||
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||
|
||||
|
||||
class VerifyKeys:
|
||||
valid_keys = frozenset()
|
||||
valid_keys_casefold: bool = False
|
||||
verify_item_name = False
|
||||
verify_location_name = False
|
||||
convert_name_groups: bool = False
|
||||
verify_item_name: bool = False
|
||||
verify_location_name: bool = False
|
||||
value: typing.Any
|
||||
|
||||
@classmethod
|
||||
def verify_keys(cls, data):
|
||||
def verify_keys(cls, data: typing.List[str]):
|
||||
if cls.valid_keys:
|
||||
data = set(data)
|
||||
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
|
||||
@@ -279,26 +733,39 @@ class VerifyKeys:
|
||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||
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:
|
||||
new_value = type(self.value)() # empty container of whatever value is
|
||||
for item_name in self.value:
|
||||
new_value |= world.item_name_groups.get(item_name, {item_name})
|
||||
self.value = new_value
|
||||
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:
|
||||
for item_name in self.value:
|
||||
if item_name not in world.item_names:
|
||||
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
|
||||
raise Exception(f"Item {item_name} from option {self} "
|
||||
f"is not a valid item name from {world.game}")
|
||||
f"is not a valid item name from {world.game}. "
|
||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||
elif self.verify_location_name:
|
||||
for location_name in self.value:
|
||||
if location_name not in world.world_types[world.game].location_names:
|
||||
if location_name not in world.location_names:
|
||||
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
|
||||
raise Exception(f"Location {location_name} from option {self} "
|
||||
f"is not a valid location name from {world.game}")
|
||||
f"is not a valid location name from {world.game}. "
|
||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||
|
||||
|
||||
class OptionDict(Option, VerifyKeys):
|
||||
default = {}
|
||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
||||
default: typing.Dict[str, typing.Any] = {}
|
||||
supports_weighting = False
|
||||
value: typing.Dict[str, typing.Any]
|
||||
|
||||
def __init__(self, value: typing.Dict[str, typing.Any]):
|
||||
self.value = value
|
||||
self.value = deepcopy(value)
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
||||
@@ -316,7 +783,6 @@ class OptionDict(Option, VerifyKeys):
|
||||
|
||||
|
||||
class ItemDict(OptionDict):
|
||||
# implemented by Generate
|
||||
verify_item_name = True
|
||||
|
||||
def __init__(self, value: typing.Dict[str, int]):
|
||||
@@ -325,13 +791,12 @@ class ItemDict(OptionDict):
|
||||
super(ItemDict, self).__init__(value)
|
||||
|
||||
|
||||
class OptionList(Option, VerifyKeys):
|
||||
default = []
|
||||
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
default: typing.List[typing.Any] = []
|
||||
supports_weighting = False
|
||||
value: list
|
||||
|
||||
def __init__(self, value: typing.List[typing.Any]):
|
||||
self.value = value or []
|
||||
self.value = deepcopy(value)
|
||||
super(OptionList, self).__init__()
|
||||
|
||||
@classmethod
|
||||
@@ -352,13 +817,12 @@ class OptionList(Option, VerifyKeys):
|
||||
return item in self.value
|
||||
|
||||
|
||||
class OptionSet(Option, VerifyKeys):
|
||||
default = frozenset()
|
||||
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||
default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
|
||||
supports_weighting = False
|
||||
value: set
|
||||
|
||||
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
|
||||
self.value = set(value)
|
||||
def __init__(self, value: typing.Iterable[str]):
|
||||
self.value = set(deepcopy(value))
|
||||
super(OptionSet, self).__init__()
|
||||
|
||||
@classmethod
|
||||
@@ -367,22 +831,21 @@ class OptionSet(Option, VerifyKeys):
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
if type(data) == list:
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
elif type(data) == set:
|
||||
if isinstance(data, (list, set, frozenset)):
|
||||
cls.verify_keys(data)
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self, value):
|
||||
return ", ".join(value)
|
||||
return ", ".join(sorted(value))
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self.value
|
||||
|
||||
|
||||
local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
|
||||
class ItemSet(OptionSet):
|
||||
verify_item_name = True
|
||||
convert_name_groups = True
|
||||
|
||||
|
||||
class Accessibility(Choice):
|
||||
@@ -398,9 +861,18 @@ class Accessibility(Choice):
|
||||
default = 1
|
||||
|
||||
|
||||
class ProgressionBalancing(DefaultOnToggle):
|
||||
"""A system that moves progression earlier, to try and prevent the player from getting stuck and bored early."""
|
||||
class ProgressionBalancing(SpecialRange):
|
||||
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||
A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
||||
default = 50
|
||||
range_start = 0
|
||||
range_end = 99
|
||||
display_name = "Progression Balancing"
|
||||
special_range_names = {
|
||||
"disabled": 0,
|
||||
"normal": 50,
|
||||
"extreme": 99,
|
||||
}
|
||||
|
||||
|
||||
common_options = {
|
||||
@@ -409,11 +881,6 @@ common_options = {
|
||||
}
|
||||
|
||||
|
||||
class ItemSet(OptionSet):
|
||||
# implemented by Generate
|
||||
verify_item_name = True
|
||||
|
||||
|
||||
class LocalItems(ItemSet):
|
||||
"""Forces these items to be in their native world."""
|
||||
display_name = "Local Items"
|
||||
@@ -435,21 +902,23 @@ class StartHints(ItemSet):
|
||||
display_name = "Start Hints"
|
||||
|
||||
|
||||
class StartLocationHints(OptionSet):
|
||||
class LocationSet(OptionSet):
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class StartLocationHints(LocationSet):
|
||||
"""Start with these locations and their item prefilled into the !hint command"""
|
||||
display_name = "Start Location Hints"
|
||||
|
||||
|
||||
class ExcludeLocations(OptionSet):
|
||||
class ExcludeLocations(LocationSet):
|
||||
"""Prevent these locations from having an important item"""
|
||||
display_name = "Excluded Locations"
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class PriorityLocations(OptionSet):
|
||||
class PriorityLocations(LocationSet):
|
||||
"""Prevent these locations from having an unimportant item"""
|
||||
display_name = "Priority Locations"
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class DeathLink(Toggle):
|
||||
@@ -464,20 +933,61 @@ class ItemLinks(OptionList):
|
||||
{
|
||||
"name": And(str, len),
|
||||
"item_pool": [And(str, len)],
|
||||
"replacement_item": Or(And(str, len), None)
|
||||
Optional("exclude"): [And(str, len)],
|
||||
"replacement_item": Or(And(str, len), None),
|
||||
Optional("local_items"): [And(str, len)],
|
||||
Optional("non_local_items"): [And(str, len)],
|
||||
Optional("link_replacement"): Or(None, bool),
|
||||
}
|
||||
])
|
||||
|
||||
def verify(self, world):
|
||||
super(ItemLinks, self).verify(world)
|
||||
@staticmethod
|
||||
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set:
|
||||
pool = set()
|
||||
for item_name in items:
|
||||
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
|
||||
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
|
||||
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1)
|
||||
picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else ""
|
||||
|
||||
raise Exception(f"Item {item_name} from item link {item_link} "
|
||||
f"is not a valid item from {world.game} for {pool_name}. "
|
||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
|
||||
if allow_item_groups:
|
||||
pool |= world.item_name_groups.get(item_name, {item_name})
|
||||
else:
|
||||
pool |= {item_name}
|
||||
return pool
|
||||
|
||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||
link: dict
|
||||
super(ItemLinks, self).verify(world, player_name, plando_options)
|
||||
existing_links = set()
|
||||
for link in self.value:
|
||||
for item_name in link["item_pool"]:
|
||||
if item_name not in world.item_names and item_name not in world.item_name_groups:
|
||||
raise Exception(f"Item {item_name} from option {self} "
|
||||
f"is not a valid item name from {world.game}")
|
||||
if link["replacement_item"] and link["replacement_item"] not in world.item_names:
|
||||
raise Exception(f"Item {link['replacement_item']} from option {self} "
|
||||
f"is not a valid item name from {world.game}")
|
||||
if link["name"] in existing_links:
|
||||
raise Exception(f"You cannot have more than one link named {link['name']}.")
|
||||
existing_links.add(link["name"])
|
||||
|
||||
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
|
||||
local_items = set()
|
||||
non_local_items = set()
|
||||
|
||||
if "exclude" in link:
|
||||
pool -= self.verify_items(link["exclude"], link["name"], "exclude", world)
|
||||
if link["replacement_item"]:
|
||||
self.verify_items([link["replacement_item"]], link["name"], "replacement_item", world, False)
|
||||
if "local_items" in link:
|
||||
local_items = self.verify_items(link["local_items"], link["name"], "local_items", world)
|
||||
local_items &= pool
|
||||
if "non_local_items" in link:
|
||||
non_local_items = self.verify_items(link["non_local_items"], link["name"], "non_local_items", world)
|
||||
non_local_items &= pool
|
||||
|
||||
intersection = local_items.intersection(non_local_items)
|
||||
if intersection:
|
||||
raise Exception(f"item_link {link['name']} has {intersection} "
|
||||
f"items in both its local_items and non_local_items pool.")
|
||||
link.setdefault("link_replacement", None)
|
||||
|
||||
|
||||
per_game_common_options = {
|
||||
@@ -492,7 +1002,6 @@ per_game_common_options = {
|
||||
"item_links": ItemLinks
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
from worlds.alttp.Options import Logic
|
||||
|
||||
278
Patch.py
@@ -1,267 +1,35 @@
|
||||
# TODO: convert this into a system like AutoWorld
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import bsdiff4
|
||||
import yaml
|
||||
import os
|
||||
import lzma
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import zipfile
|
||||
import sys
|
||||
from typing import Tuple, Optional
|
||||
from typing import Tuple, Optional, TypedDict
|
||||
|
||||
import Utils
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
current_patch_version = 3
|
||||
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
GAME_SM = "Super Metroid"
|
||||
GAME_SOE = "Secret of Evermore"
|
||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore"}
|
||||
|
||||
preferred_endings = {
|
||||
GAME_ALTTP: "apbp",
|
||||
GAME_SM: "apm3",
|
||||
GAME_SOE: "apsoe"
|
||||
}
|
||||
from worlds.Files import AutoPatchRegister, APDeltaPatch
|
||||
|
||||
|
||||
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||
if game == GAME_ALTTP:
|
||||
from worlds.alttp.Rom import JAP10HASH as HASH
|
||||
elif game == GAME_SM:
|
||||
from worlds.sm.Rom import JAP10HASH as HASH
|
||||
elif game == GAME_SOE:
|
||||
from worlds.soe.Patch import USHASH as HASH
|
||||
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")
|
||||
class RomMeta(TypedDict):
|
||||
server: str
|
||||
player: Optional[int]
|
||||
player_name: str
|
||||
|
||||
|
||||
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 ".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:
|
||||
file_name = Utils.get_options()["soe_options"]["rom_file"]
|
||||
get_base_rom_bytes = lambda: bytes(read_rom(open(file_name, "rb")))
|
||||
else:
|
||||
raise RuntimeError("Selected game for base rom not found.")
|
||||
return get_base_rom_bytes()
|
||||
|
||||
|
||||
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
||||
data, target, patched_data = create_rom_bytes(patch_file)
|
||||
with open(target, "wb") as f:
|
||||
f.write(patched_data)
|
||||
return data, target
|
||||
|
||||
|
||||
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
|
||||
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
|
||||
auto_handler = AutoPatchRegister.get_handler(patch_file)
|
||||
if auto_handler:
|
||||
handler: APDeltaPatch = auto_handler(patch_file)
|
||||
target = os.path.splitext(patch_file)[0]+handler.result_file_ending
|
||||
handler.patch(target)
|
||||
return {"server": handler.server,
|
||||
"player": handler.player,
|
||||
"player_name": handler.player_name}, target
|
||||
raise NotImplementedError(f"No Handler for {patch_file} found.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = Utils.get_public_ipv4()
|
||||
options = Utils.get_options()['server_options']
|
||||
if options['host']:
|
||||
host = options['host']
|
||||
|
||||
address = f"{host}:{options['port']}"
|
||||
ziplock = threading.Lock()
|
||||
print(f"Host for patches to be created is {address}")
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
for rom in sys.argv:
|
||||
try:
|
||||
if rom.endswith(".sfc"):
|
||||
print(f"Creating patch for {rom}")
|
||||
result = pool.submit(create_patch_file, rom, address)
|
||||
result.add_done_callback(lambda task: print(f"Created patch {task.result()}"))
|
||||
|
||||
elif rom.endswith(".apbp"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
#romfile, adjusted = Utils.get_adjuster_settings(target)
|
||||
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(".archipelago"):
|
||||
import json
|
||||
import zlib
|
||||
|
||||
with open(rom, 'rb') as fr:
|
||||
|
||||
multidata = zlib.decompress(fr.read()).decode("utf-8")
|
||||
with open(rom + '.txt', 'w') as fw:
|
||||
fw.write(multidata)
|
||||
multidata = json.loads(multidata)
|
||||
for romname in multidata['roms']:
|
||||
Utils.persistent_store("servers", "".join(chr(byte) for byte in romname[2]), address)
|
||||
from Utils import get_options
|
||||
|
||||
multidata["server_options"] = get_options()["server_options"]
|
||||
multidata = zlib.compress(json.dumps(multidata).encode("utf-8"), 9)
|
||||
with open(rom + "_updated.archipelago", 'wb') as f:
|
||||
f.write(multidata)
|
||||
|
||||
elif rom.endswith(".zip"):
|
||||
print(f"Updating host in patch files contained in {rom}")
|
||||
|
||||
|
||||
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
||||
data = zfr.read(zfinfo)
|
||||
if zfinfo.filename.endswith(".apbp") or zfinfo.filename.endswith(".apm3"):
|
||||
data = update_patch_data(data, server)
|
||||
with ziplock:
|
||||
zfw.writestr(zfinfo, data)
|
||||
return zfinfo.filename
|
||||
|
||||
|
||||
futures = []
|
||||
with zipfile.ZipFile(rom, "r") as zfr:
|
||||
updated_zip = os.path.splitext(rom)[0] + "_updated.zip"
|
||||
with zipfile.ZipFile(updated_zip, "w", compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9) as zfw:
|
||||
for zfname in zfr.namelist():
|
||||
futures.append(pool.submit(_handle_zip_file_entry, zfr.getinfo(zfname), address))
|
||||
for future in futures:
|
||||
print(f"File {future.result()} added to {os.path.split(updated_zip)[1]}")
|
||||
|
||||
except:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
input("Press enter to close.")
|
||||
for file in sys.argv[1:]:
|
||||
meta_data, result_file = create_rom_file(file)
|
||||
print(f"Patch with meta-data {meta_data} was written to {result_file}")
|
||||
|
||||
351
PokemonClient.py
Normal file
@@ -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 Bizhawk 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()
|
||||
42
README.md
@@ -18,6 +18,27 @@ Currently, the following games are supported:
|
||||
* VVVVVV
|
||||
* Raft
|
||||
* Super Mario 64
|
||||
* Meritous
|
||||
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
||||
* ChecksFinder
|
||||
* ArchipIDLE
|
||||
* Hollow Knight
|
||||
* The Witness
|
||||
* Sonic Adventure 2: Battle
|
||||
* Starcraft 2: Wings of Liberty
|
||||
* Donkey Kong Country 3
|
||||
* Dark Souls 3
|
||||
* Super Mario World
|
||||
* Pokémon Red and Blue
|
||||
* Hylics 2
|
||||
* Overcooked! 2
|
||||
* Zillion
|
||||
* Lufia II Ancient Cave
|
||||
* Blasphemous
|
||||
* Wargroove
|
||||
* Stardew Valley
|
||||
* The Legend of Zelda
|
||||
* The Messenger
|
||||
|
||||
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
|
||||
@@ -41,7 +62,7 @@ Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEnt
|
||||
## Running Archipelago
|
||||
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
|
||||
|
||||
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our wiki page on [running Archipelago from source](https://github.com/ArchipelagoMW/Archipelago/wiki/Running-from-source).
|
||||
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our doc on [running Archipelago from source](docs/running%20from%20source.md).
|
||||
|
||||
## Related Repositories
|
||||
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
|
||||
@@ -51,21 +72,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)
|
||||
|
||||
## Contributing
|
||||
Contributions are welcome. We have a few asks of any new contributors.
|
||||
For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md)
|
||||
|
||||
* Ensure that all changes which affect logic are covered by unit tests.
|
||||
* Do not introduce any unit test failures/regressions.
|
||||
|
||||
Otherwise, we tend to judge code on a case to case basis. It is a generally good idea to stick to PEP-8 guidelines to ensure consistency with existing code. (And to make the linter happy.)
|
||||
## FAQ
|
||||
For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/)
|
||||
|
||||
## Code of Conduct
|
||||
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:
|
||||
|
||||
* Be welcoming and inclusive in tone and language.
|
||||
* Be respectful of others and their abilities.
|
||||
* Show empathy when speaking with others.
|
||||
* Be gracious and accept feedback and constructive criticism.
|
||||
|
||||
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private, such as private messaging or emails.
|
||||
|
||||
Any incidents of abuse may be reported directly to Ijwu at hmfarran@gmail.com.
|
||||
Please refer to our [code of conduct.](/docs/code_of_conduct.md)
|
||||
|
||||
1120
SNIClient.py
1052
Starcraft2Client.py
Normal file
455
Utils.py
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import typing
|
||||
import builtins
|
||||
import os
|
||||
@@ -11,7 +12,20 @@ import io
|
||||
import collections
|
||||
import importlib
|
||||
import logging
|
||||
from tkinter import Tk
|
||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
|
||||
|
||||
from yaml import load, load_all, dump, SafeLoader
|
||||
|
||||
try:
|
||||
from yaml import CLoader as UnsafeLoader
|
||||
from yaml import CDumper as Dumper
|
||||
except ImportError:
|
||||
from yaml import Loader as UnsafeLoader
|
||||
from yaml import Dumper
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import tkinter
|
||||
import pathlib
|
||||
|
||||
|
||||
def tuplize_version(version: str) -> Version:
|
||||
@@ -24,58 +38,58 @@ class Version(typing.NamedTuple):
|
||||
build: int
|
||||
|
||||
|
||||
__version__ = "0.2.5"
|
||||
__version__ = "0.3.9"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
from yaml import load, dump, SafeLoader
|
||||
|
||||
try:
|
||||
from yaml import CLoader as Loader
|
||||
except ImportError:
|
||||
from yaml import Loader
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
is_macos = sys.platform == "darwin"
|
||||
is_windows = sys.platform in ("win32", "cygwin", "msys")
|
||||
|
||||
|
||||
def int16_as_bytes(value):
|
||||
def int16_as_bytes(value: int) -> typing.List[int]:
|
||||
value = value & 0xFFFF
|
||||
return [value & 0xFF, (value >> 8) & 0xFF]
|
||||
|
||||
|
||||
def int32_as_bytes(value):
|
||||
def int32_as_bytes(value: int) -> typing.List[int]:
|
||||
value = value & 0xFFFFFFFF
|
||||
return [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF, (value >> 24) & 0xFF]
|
||||
|
||||
|
||||
def pc_to_snes(value):
|
||||
def pc_to_snes(value: int) -> int:
|
||||
return ((value << 1) & 0x7F0000) | (value & 0x7FFF) | 0x8000
|
||||
|
||||
|
||||
def snes_to_pc(value):
|
||||
def snes_to_pc(value: int) -> int:
|
||||
return ((value & 0x7F0000) >> 1) | (value & 0x7FFF)
|
||||
|
||||
|
||||
def cache_argsless(function):
|
||||
if function.__code__.co_argcount:
|
||||
raise Exception("Can only cache 0 argument functions with this cache.")
|
||||
RetType = typing.TypeVar("RetType")
|
||||
|
||||
result = sentinel = object()
|
||||
|
||||
def _wrap():
|
||||
def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]:
|
||||
assert not function.__code__.co_argcount, "Can only cache 0 argument functions with this cache."
|
||||
|
||||
sentinel = object()
|
||||
result: typing.Union[object, RetType] = sentinel
|
||||
|
||||
def _wrap() -> RetType:
|
||||
nonlocal result
|
||||
if result is sentinel:
|
||||
result = function()
|
||||
return result
|
||||
return typing.cast(RetType, result)
|
||||
|
||||
return _wrap
|
||||
|
||||
|
||||
def is_frozen() -> bool:
|
||||
return getattr(sys, 'frozen', False)
|
||||
return typing.cast(bool, getattr(sys, 'frozen', False))
|
||||
|
||||
|
||||
def local_path(*path):
|
||||
if local_path.cached_path:
|
||||
return os.path.join(local_path.cached_path, *path)
|
||||
|
||||
def local_path(*path: str) -> str:
|
||||
"""Returns path to a file in the local Archipelago installation or source."""
|
||||
if hasattr(local_path, 'cached_path'):
|
||||
pass
|
||||
elif is_frozen():
|
||||
if hasattr(sys, "_MEIPASS"):
|
||||
# we are running in a PyInstaller bundle
|
||||
@@ -85,7 +99,7 @@ def local_path(*path):
|
||||
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
||||
else:
|
||||
import __main__
|
||||
if hasattr(__main__, "__file__"):
|
||||
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
||||
# we are running in a normal Python environment
|
||||
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
||||
else:
|
||||
@@ -95,26 +109,54 @@ def local_path(*path):
|
||||
return os.path.join(local_path.cached_path, *path)
|
||||
|
||||
|
||||
local_path.cached_path = None
|
||||
def home_path(*path: str) -> str:
|
||||
"""Returns path to a file in the user home's Archipelago directory."""
|
||||
if hasattr(home_path, 'cached_path'):
|
||||
pass
|
||||
elif sys.platform.startswith('linux'):
|
||||
home_path.cached_path = os.path.expanduser('~/Archipelago')
|
||||
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
||||
else:
|
||||
# not implemented
|
||||
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
|
||||
|
||||
return os.path.join(home_path.cached_path, *path)
|
||||
|
||||
|
||||
def output_path(*path):
|
||||
if output_path.cached_path:
|
||||
def user_path(*path: str) -> str:
|
||||
"""Returns either local_path or home_path based on write permissions."""
|
||||
if hasattr(user_path, "cached_path"):
|
||||
pass
|
||||
elif os.access(local_path(), os.W_OK):
|
||||
user_path.cached_path = local_path()
|
||||
else:
|
||||
user_path.cached_path = home_path()
|
||||
# populate home from local - TODO: upgrade feature
|
||||
if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")):
|
||||
import shutil
|
||||
for dn in ("Players", "data/sprites"):
|
||||
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
|
||||
for fn in ("manifest.json", "host.yaml"):
|
||||
shutil.copy2(local_path(fn), user_path(fn))
|
||||
|
||||
return os.path.join(user_path.cached_path, *path)
|
||||
|
||||
|
||||
def output_path(*path: str) -> str:
|
||||
if hasattr(output_path, 'cached_path'):
|
||||
return os.path.join(output_path.cached_path, *path)
|
||||
output_path.cached_path = local_path(get_options()["general_options"]["output_path"])
|
||||
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
|
||||
path = os.path.join(output_path.cached_path, *path)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
output_path.cached_path = None
|
||||
|
||||
|
||||
def open_file(filename):
|
||||
if sys.platform == 'win32':
|
||||
def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
|
||||
if is_windows:
|
||||
os.startfile(filename)
|
||||
else:
|
||||
open_command = 'open' if sys.platform == 'darwin' else 'xdg-open'
|
||||
from shutil import which
|
||||
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
|
||||
subprocess.call([open_command, filename])
|
||||
|
||||
|
||||
@@ -132,7 +174,10 @@ class UniqueKeyLoader(SafeLoader):
|
||||
|
||||
|
||||
parse_yaml = functools.partial(load, Loader=UniqueKeyLoader)
|
||||
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
|
||||
parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader)
|
||||
unsafe_parse_yaml = functools.partial(load, Loader=UnsafeLoader)
|
||||
|
||||
del load, load_all # should not be used. don't leak their names
|
||||
|
||||
|
||||
def get_cert_none_ssl_context():
|
||||
@@ -150,11 +195,12 @@ def get_public_ipv4() -> str:
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
ctx = get_cert_none_ssl_context()
|
||||
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:
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
ip = urllib.request.urlopen('https://v4.ident.me', context=ctx).read().decode('utf8').strip()
|
||||
except:
|
||||
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
|
||||
except Exception:
|
||||
logging.exception(e)
|
||||
pass # we could be offline, in a local game, so no point in erroring out
|
||||
return ip
|
||||
@@ -167,15 +213,18 @@ def get_public_ipv6() -> str:
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
ctx = get_cert_none_ssl_context()
|
||||
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:
|
||||
logging.exception(e)
|
||||
pass # we could be offline, in a local game, or ipv6 may not be available
|
||||
return ip
|
||||
|
||||
|
||||
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
|
||||
|
||||
|
||||
@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.
|
||||
options = {
|
||||
"general_options": {
|
||||
@@ -183,20 +232,21 @@ def get_default_options() -> dict:
|
||||
},
|
||||
"factorio_options": {
|
||||
"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": {
|
||||
"rom_file": "Super Metroid (JU).sfc",
|
||||
"sni": "SNI",
|
||||
"rom_start": True,
|
||||
},
|
||||
"soe_options": {
|
||||
"rom_file": "Secret of Evermore (USA).sfc",
|
||||
},
|
||||
"lttp_options": {
|
||||
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
||||
"sni": "SNI",
|
||||
"rom_start": True,
|
||||
|
||||
},
|
||||
"server_options": {
|
||||
"host": None,
|
||||
@@ -210,7 +260,7 @@ def get_default_options() -> dict:
|
||||
"disable_item_cheat": False,
|
||||
"location_check_points": 1,
|
||||
"hint_cost": 10,
|
||||
"forfeit_mode": "goal",
|
||||
"release_mode": "goal",
|
||||
"collect_mode": "disabled",
|
||||
"remaining_mode": "goal",
|
||||
"auto_shutdown": 0,
|
||||
@@ -218,30 +268,61 @@ def get_default_options() -> dict:
|
||||
"log_network": 0
|
||||
},
|
||||
"generator": {
|
||||
"teams": 1,
|
||||
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core.exe"),
|
||||
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
|
||||
"player_files_path": "Players",
|
||||
"players": 0,
|
||||
"weights_file_path": "weights.yaml",
|
||||
"meta_file_path": "meta.yaml",
|
||||
"spoiler": 2,
|
||||
"spoiler": 3,
|
||||
"glitch_triforce_room": 1,
|
||||
"race": 0,
|
||||
"plando_options": "bosses",
|
||||
},
|
||||
"minecraft_options": {
|
||||
"forge_directory": "Minecraft Forge server",
|
||||
"max_heap_size": "2G"
|
||||
"max_heap_size": "2G",
|
||||
"release_channel": "release"
|
||||
},
|
||||
"oot_options": {
|
||||
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
|
||||
"rom_start": True
|
||||
},
|
||||
"dkc3_options": {
|
||||
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
|
||||
},
|
||||
"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,
|
||||
"display_msgs": True,
|
||||
},
|
||||
"wargroove_options": {
|
||||
"root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove"
|
||||
}
|
||||
}
|
||||
|
||||
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():
|
||||
new_keys = keys.copy()
|
||||
new_keys.append(key)
|
||||
@@ -261,47 +342,36 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_options() -> dict:
|
||||
if not hasattr(get_options, "options"):
|
||||
locations = ("options.yaml", "host.yaml",
|
||||
local_path("options.yaml"), local_path("host.yaml"))
|
||||
def get_options() -> OptionsType:
|
||||
filenames = ("options.yaml", "host.yaml")
|
||||
locations: typing.List[str] = []
|
||||
if os.path.join(os.getcwd()) != local_path():
|
||||
locations += filenames # use files from cwd only if it's not the local_path
|
||||
locations += [user_path(filename) for filename in filenames]
|
||||
|
||||
for location in locations:
|
||||
if os.path.exists(location):
|
||||
with open(location) as f:
|
||||
options = parse_yaml(f.read())
|
||||
for location in locations:
|
||||
if os.path.exists(location):
|
||||
with open(location) as f:
|
||||
options = parse_yaml(f.read())
|
||||
return update_options(get_default_options(), options, location, list())
|
||||
|
||||
get_options.options = update_options(get_default_options(), options, location, list())
|
||||
break
|
||||
else:
|
||||
raise FileNotFoundError(f"Could not find {locations[1]} to load options.")
|
||||
return get_options.options
|
||||
|
||||
|
||||
def get_item_name_from_id(code: int) -> str:
|
||||
from worlds import lookup_any_item_id_to_name
|
||||
return lookup_any_item_id_to_name.get(code, f'Unknown item (ID:{code})')
|
||||
|
||||
|
||||
def get_location_name_from_id(code: int) -> str:
|
||||
from worlds import lookup_any_location_id_to_name
|
||||
return lookup_any_location_id_to_name.get(code, f'Unknown location (ID:{code})')
|
||||
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
|
||||
|
||||
|
||||
def persistent_store(category: str, key: typing.Any, value: typing.Any):
|
||||
path = local_path("_persistent_storage.yaml")
|
||||
path = user_path("_persistent_storage.yaml")
|
||||
storage: dict = persistent_load()
|
||||
category = storage.setdefault(category, {})
|
||||
category[key] = value
|
||||
with open(path, "wt") as f:
|
||||
f.write(dump(storage))
|
||||
f.write(dump(storage, Dumper=Dumper))
|
||||
|
||||
|
||||
def persistent_load() -> typing.Dict[dict]:
|
||||
def persistent_load() -> typing.Dict[str, dict]:
|
||||
storage = getattr(persistent_load, "storage", None)
|
||||
if storage:
|
||||
return storage
|
||||
path = local_path("_persistent_storage.yaml")
|
||||
path = user_path("_persistent_storage.yaml")
|
||||
storage: dict = {}
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
@@ -315,8 +385,8 @@ def persistent_load() -> typing.Dict[dict]:
|
||||
return storage
|
||||
|
||||
|
||||
def get_adjuster_settings(gameName: str):
|
||||
adjuster_settings = persistent_load().get("adjuster", {}).get(gameName, {})
|
||||
def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]:
|
||||
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
|
||||
return adjuster_settings
|
||||
|
||||
|
||||
@@ -332,10 +402,10 @@ def get_unique_identifier():
|
||||
return uuid
|
||||
|
||||
|
||||
safe_builtins = {
|
||||
safe_builtins = frozenset((
|
||||
'set',
|
||||
'frozenset',
|
||||
}
|
||||
))
|
||||
|
||||
|
||||
class RestrictedUnpickler(pickle.Unpickler):
|
||||
@@ -354,7 +424,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
# Options and Plando are unpickled by WebHost -> Generate
|
||||
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
||||
return getattr(self.generic_properties_module, name)
|
||||
if module.endswith("Options"):
|
||||
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
|
||||
if module.lower().endswith("options"):
|
||||
if module == "Options":
|
||||
mod = self.options_module
|
||||
else:
|
||||
@@ -363,8 +434,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
if issubclass(obj, self.options_module.Option):
|
||||
return obj
|
||||
# Forbid everything else.
|
||||
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
|
||||
(module, name))
|
||||
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||
|
||||
|
||||
def restricted_loads(s):
|
||||
@@ -373,6 +443,9 @@ def restricted_loads(s):
|
||||
|
||||
|
||||
class KeyedDefaultDict(collections.defaultdict):
|
||||
"""defaultdict variant that uses the missing key as argument to default_factory"""
|
||||
default_factory: typing.Callable[[typing.Any], typing.Any]
|
||||
|
||||
def __missing__(self, key):
|
||||
self[key] = value = self.default_factory(key)
|
||||
return value
|
||||
@@ -382,19 +455,27 @@ def get_text_between(text: str, start: str, end: str) -> str:
|
||||
return text[text.index(start) + len(start): text.rindex(end)]
|
||||
|
||||
|
||||
def get_text_after(text: str, start: str) -> str:
|
||||
return text[text.index(start) + len(start):]
|
||||
|
||||
|
||||
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
|
||||
|
||||
|
||||
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
|
||||
log_format: str = "[%(name)s]: %(message)s", exception_logger: str = ""):
|
||||
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
||||
exception_logger: typing.Optional[str] = None):
|
||||
import datetime
|
||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||
log_folder = local_path("logs")
|
||||
log_folder = user_path("logs")
|
||||
os.makedirs(log_folder, exist_ok=True)
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
handler.close()
|
||||
root_logger.setLevel(loglevel)
|
||||
if "a" not in write_mode:
|
||||
name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}"
|
||||
file_handler = logging.FileHandler(
|
||||
os.path.join(log_folder, f"{name}.txt"),
|
||||
write_mode,
|
||||
@@ -422,13 +503,37 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
|
||||
sys.excepthook = handle_exception
|
||||
|
||||
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 queuer():
|
||||
while 1:
|
||||
text = stream.readline().strip()
|
||||
if text:
|
||||
queue.put_nowait(text)
|
||||
try:
|
||||
text = stream.readline().strip()
|
||||
except UnicodeDecodeError as e:
|
||||
logging.exception(e)
|
||||
else:
|
||||
if text:
|
||||
queue.put_nowait(text)
|
||||
|
||||
from threading import Thread
|
||||
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
|
||||
@@ -436,13 +541,171 @@ def stream_input(stream, queue):
|
||||
return thread
|
||||
|
||||
|
||||
def tkinter_center_window(window: Tk):
|
||||
def tkinter_center_window(window: "tkinter.Tk") -> None:
|
||||
window.update()
|
||||
xPos = int(window.winfo_screenwidth()/2 - window.winfo_reqwidth()/2)
|
||||
yPos = int(window.winfo_screenheight()/2 - window.winfo_reqheight()/2)
|
||||
window.geometry("+{}+{}".format(xPos, yPos))
|
||||
x = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2)
|
||||
y = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2)
|
||||
window.geometry(f"+{x}+{y}")
|
||||
|
||||
|
||||
|
||||
class VersionException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
|
||||
text = ""
|
||||
max_label = len(labels) - 1
|
||||
while index > max_label:
|
||||
text += labels[-1]
|
||||
index -= max_label
|
||||
return labels[index] + text
|
||||
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P", "E", "Z", "Y")) -> str:
|
||||
"""Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix"""
|
||||
import decimal
|
||||
n = 0
|
||||
value = decimal.Decimal(value)
|
||||
limit = power - decimal.Decimal("0.005")
|
||||
while value >= limit:
|
||||
value /= power
|
||||
n += 1
|
||||
|
||||
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
|
||||
|
||||
|
||||
def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
|
||||
-> typing.List[typing.Tuple[str, int]]:
|
||||
import jellyfish
|
||||
|
||||
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
||||
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
||||
/ max(len(word1), len(word2)))
|
||||
|
||||
limit: int = limit if limit else len(wordlist)
|
||||
return list(
|
||||
map(
|
||||
lambda container: (container[0], int(container[1]*100)), # convert up to limit to int %
|
||||
sorted(
|
||||
map(lambda candidate:
|
||||
(candidate, get_fuzzy_ratio(input_word, candidate)),
|
||||
wordlist),
|
||||
key=lambda element: element[1],
|
||||
reverse=True)[0:limit]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \
|
||||
-> typing.Optional[str]:
|
||||
def run(*args: str):
|
||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
||||
|
||||
if is_linux:
|
||||
# prefer native dialog
|
||||
from shutil import which
|
||||
kdialog = which("kdialog")
|
||||
if kdialog:
|
||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||
return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters)
|
||||
zenity = which("zenity")
|
||||
if zenity:
|
||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||
return run(zenity, f"--title={title}", "--file-selection", *z_filters)
|
||||
|
||||
# fall back to tk
|
||||
try:
|
||||
import tkinter
|
||||
import tkinter.filedialog
|
||||
except Exception as e:
|
||||
logging.error('Could not load tkinter, which is likely not installed. '
|
||||
f'This attempt was made because open_filename was used for "{title}".')
|
||||
raise e
|
||||
else:
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes))
|
||||
|
||||
|
||||
def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
def run(*args: str):
|
||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
||||
|
||||
def is_kivy_running():
|
||||
if "kivy" in sys.modules:
|
||||
from kivy.app import App
|
||||
return App.get_running_app() is not None
|
||||
return False
|
||||
|
||||
if is_kivy_running():
|
||||
from kvui import MessageBox
|
||||
MessageBox(title, text, error).open()
|
||||
return
|
||||
|
||||
if is_linux and "tkinter" not in sys.modules:
|
||||
# prefer native dialog
|
||||
from shutil import which
|
||||
kdialog = which("kdialog")
|
||||
if kdialog:
|
||||
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
|
||||
zenity = which("zenity")
|
||||
if zenity:
|
||||
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
|
||||
|
||||
# fall back to tk
|
||||
try:
|
||||
import tkinter
|
||||
from tkinter.messagebox import showerror, showinfo
|
||||
except Exception as e:
|
||||
logging.error('Could not load tkinter, which is likely not installed. '
|
||||
f'This attempt was made because messagebox was used for "{title}".')
|
||||
raise e
|
||||
else:
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
showerror(title, text) if error else showinfo(title, text)
|
||||
root.update()
|
||||
|
||||
|
||||
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."""
|
||||
def sorter(element: Union[str, Dict[str, Any]]) -> str:
|
||||
if (not isinstance(element, str)):
|
||||
element = element["title"]
|
||||
|
||||
parts = element.split(maxsplit=1)
|
||||
if parts[0].lower() in ignore:
|
||||
return parts[1].lower()
|
||||
else:
|
||||
return element.lower()
|
||||
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[None]]" = set()
|
||||
|
||||
|
||||
def async_start(co: Coroutine[typing.Any, typing.Any, bool], 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.create_task(co, name=name)
|
||||
_faf_tasks.add(task)
|
||||
task.add_done_callback(_faf_tasks.discard)
|
||||
|
||||
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()
|
||||
89
WebHost.py
@@ -1,16 +1,19 @@
|
||||
import os
|
||||
import multiprocessing
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt"))
|
||||
ModuleUpdate.update()
|
||||
|
||||
# in case app gets imported by something like gunicorn
|
||||
import Utils
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__)
|
||||
|
||||
from WebHostLib import app as raw_app
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
||||
|
||||
from WebHostLib import register, app as raw_app
|
||||
from waitress import serve
|
||||
|
||||
from WebHostLib.models import db
|
||||
@@ -19,19 +22,97 @@ from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
from WebHostLib.options import create as create_options_files
|
||||
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||
|
||||
|
||||
def get_app():
|
||||
register()
|
||||
app = raw_app
|
||||
if os.path.exists(configpath):
|
||||
if os.path.exists(configpath) and not app.config["TESTING"]:
|
||||
import yaml
|
||||
app.config.from_file(configpath, yaml.safe_load)
|
||||
logging.info(f"Updated config from {configpath}")
|
||||
if not app.config["HOST_ADDRESS"]:
|
||||
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
||||
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
|
||||
|
||||
db.bind(**app.config["PONY"])
|
||||
db.generate_mapping(create_tables=True)
|
||||
return app
|
||||
|
||||
|
||||
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
||||
import json
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
zfile: zipfile.ZipInfo
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
worlds = {}
|
||||
data = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
||||
worlds[game] = world
|
||||
|
||||
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
|
||||
for game, world in worlds.items():
|
||||
# copy files from world's docs folder to the generated folder
|
||||
target_path = os.path.join(base_target_path, game)
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
|
||||
if world.zip_path:
|
||||
zipfile_path = world.zip_path
|
||||
|
||||
assert os.path.isfile(zipfile_path), f"{zipfile_path} is not a valid file(path)."
|
||||
assert zipfile.is_zipfile(zipfile_path), f"{zipfile_path} is not a valid zipfile."
|
||||
|
||||
with zipfile.ZipFile(zipfile_path) as zf:
|
||||
for zfile in zf.infolist():
|
||||
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
||||
zf.extract(zfile, target_path)
|
||||
else:
|
||||
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
||||
files = os.listdir(source_path)
|
||||
for file in files:
|
||||
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
|
||||
|
||||
# build a json tutorial dict per game
|
||||
game_data = {'gameTitle': game, 'tutorials': []}
|
||||
for tutorial in world.web.tutorials:
|
||||
# build dict for the json file
|
||||
current_tutorial = {
|
||||
'name': tutorial.tutorial_name,
|
||||
'description': tutorial.description,
|
||||
'files': [{
|
||||
'language': tutorial.language,
|
||||
'filename': game + '/' + tutorial.file_name,
|
||||
'link': f'{game}/{tutorial.link}',
|
||||
'authors': tutorial.authors
|
||||
}]
|
||||
}
|
||||
|
||||
# check if the name of the current guide exists already
|
||||
for guide in game_data['tutorials']:
|
||||
if guide and tutorial.tutorial_name == guide['name']:
|
||||
guide['files'].append(current_tutorial['files'][0])
|
||||
break
|
||||
else:
|
||||
game_data['tutorials'].append(current_tutorial)
|
||||
|
||||
data.append(game_data)
|
||||
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
|
||||
generic_data = {}
|
||||
for games in data:
|
||||
if 'Archipelago' in games['gameTitle']:
|
||||
generic_data = data.pop(data.index(games))
|
||||
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
|
||||
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
||||
return sorted_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
multiprocessing.set_start_method('spawn')
|
||||
@@ -43,13 +124,13 @@ if __name__ == "__main__":
|
||||
logging.warning("Could not update LttP sprites.")
|
||||
app = get_app()
|
||||
create_options_files()
|
||||
create_ordered_tutorials_file()
|
||||
if app.config["SELFLAUNCH"]:
|
||||
autohost(app.config)
|
||||
if app.config["SELFGEN"]:
|
||||
autogen(app.config)
|
||||
if app.config["SELFHOST"]: # using WSGI, you just want to run get_app()
|
||||
if app.config["DEBUG"]:
|
||||
autohost(app.config)
|
||||
app.run(debug=True, port=app.config["PORT"])
|
||||
else:
|
||||
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
|
||||
|
||||
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 os
|
||||
import socket
|
||||
import uuid
|
||||
|
||||
import jinja2.exceptions
|
||||
from pony.flask import Pony
|
||||
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||
from flask import Flask
|
||||
from flask_caching import Cache
|
||||
from flask_compress import Compress
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from pony.flask import Pony
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from .models import *
|
||||
from Utils import title_sorted
|
||||
|
||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||
LOGS_FOLDER = os.path.relpath('logs')
|
||||
@@ -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["GENERATORS"] = 8 # maximum concurrent world gens
|
||||
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
|
||||
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
|
||||
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
||||
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
||||
app.config["DEBUG"] = False
|
||||
app.config["PORT"] = 80
|
||||
@@ -32,8 +33,10 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
||||
# if you want to deploy, make sure you have a non-guessable secret key
|
||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the webthread
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
||||
app.config["JOB_THRESHOLD"] = 2
|
||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||
app.config["JOB_TIME"] = 600
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
|
||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||
@@ -46,15 +49,13 @@ app.config["PONY"] = {
|
||||
'create_db': True
|
||||
}
|
||||
app.config["MAX_ROLL"] = 20
|
||||
app.config["CACHE_TYPE"] = "simple"
|
||||
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
|
||||
app.config["JSON_AS_ASCII"] = False
|
||||
app.config["PATCH_TARGET"] = "archipelago.gg"
|
||||
app.config["HOST_ADDRESS"] = ""
|
||||
|
||||
cache = Cache(app)
|
||||
Compress(app)
|
||||
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
|
||||
class B64UUIDConverter(BaseConverter):
|
||||
|
||||
@@ -68,140 +69,20 @@ class B64UUIDConverter(BaseConverter):
|
||||
# short UUID
|
||||
app.url_map.converters["suuid"] = B64UUIDConverter
|
||||
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||
app.jinja_env.filters["title_sorted"] = title_sorted
|
||||
|
||||
|
||||
@app.before_request
|
||||
def register_session():
|
||||
session.permanent = True # technically 31 days after the last visit
|
||||
if not session.get("_id", None):
|
||||
session["_id"] = uuid4() # uniquely identify each session without needing a login
|
||||
def register():
|
||||
"""Import submodules, triggering their registering on flask routing.
|
||||
Note: initializes worlds subsystem."""
|
||||
# has automatic patch integration
|
||||
import worlds.AutoWorld
|
||||
import worlds.Files
|
||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
|
||||
game_name in worlds.Files.AutoPatchRegister.patch_types
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
# to trigger app routing picking up on it
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
|
||||
|
||||
@app.errorhandler(404)
|
||||
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
||||
def page_not_found(err):
|
||||
return render_template('404.html'), 404
|
||||
|
||||
|
||||
# Start Playing Page
|
||||
@app.route('/start-playing')
|
||||
def start_playing():
|
||||
return render_template(f"startPlaying.html")
|
||||
|
||||
|
||||
@app.route('/weighted-settings')
|
||||
def weighted_settings():
|
||||
return render_template(f"weighted-settings.html")
|
||||
|
||||
|
||||
# Player settings pages
|
||||
@app.route('/games/<string:game>/player-settings')
|
||||
def player_settings(game):
|
||||
return render_template(f"player-settings.html", game=game)
|
||||
|
||||
|
||||
# Game Info Pages
|
||||
@app.route('/games/<string:game>/info/<string:lang>')
|
||||
def game_info(game, lang):
|
||||
return render_template('gameInfo.html', game=game, lang=lang)
|
||||
|
||||
|
||||
# List of supported games
|
||||
@app.route('/games')
|
||||
def games():
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world.__doc__ if world.__doc__ else "No description provided."
|
||||
return render_template("supportedGames.html", worlds=worlds)
|
||||
|
||||
|
||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||
def tutorial(game, file, lang):
|
||||
return render_template("tutorial.html", game=game, file=file, lang=lang)
|
||||
|
||||
|
||||
@app.route('/tutorial/')
|
||||
def tutorial_landing():
|
||||
return render_template("tutorialLanding.html")
|
||||
|
||||
|
||||
@app.route('/faq/<string:lang>/')
|
||||
def faq(lang):
|
||||
return render_template("faq.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/seed/<suuid:seed>')
|
||||
def view_seed(seed: UUID):
|
||||
seed = Seed.get(id=seed)
|
||||
if not seed:
|
||||
abort(404)
|
||||
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
|
||||
|
||||
|
||||
@app.route('/new_room/<suuid:seed>')
|
||||
def new_room(seed: UUID):
|
||||
seed = Seed.get(id=seed)
|
||||
if not seed:
|
||||
abort(404)
|
||||
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
|
||||
commit()
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
|
||||
def _read_log(path: str):
|
||||
if os.path.exists(path):
|
||||
with open(path, encoding="utf-8-sig") as log:
|
||||
yield from log
|
||||
else:
|
||||
yield f"Logfile {path} does not exist. " \
|
||||
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
|
||||
|
||||
|
||||
@app.route('/log/<suuid:room>')
|
||||
def display_log(room: UUID):
|
||||
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||
|
||||
|
||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||
def host_room(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if request.method == "POST":
|
||||
if room.owner == session["_id"]:
|
||||
cmd = request.form["cmd"]
|
||||
if cmd:
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
|
||||
with db_session:
|
||||
room.last_activity = datetime.utcnow() # will trigger a spinup, if it's not already running
|
||||
|
||||
return render_template("hostRoom.html", room=room)
|
||||
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
return send_from_directory(os.path.join(app.root_path, 'static/static'),
|
||||
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
||||
|
||||
|
||||
@app.route('/discord')
|
||||
def discord():
|
||||
return redirect("https://discord.gg/archipelago")
|
||||
|
||||
|
||||
@app.route('/datapackage')
|
||||
@cache.cached()
|
||||
def get_datapackge():
|
||||
"""A pretty print version of /api/datapackage"""
|
||||
from worlds import network_data_package
|
||||
import json
|
||||
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
|
||||
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
|
||||
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""API endpoints package."""
|
||||
from uuid import UUID
|
||||
from typing import List, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Blueprint, abort
|
||||
|
||||
from ..models import Room, Seed
|
||||
from .. import cache
|
||||
from ..models import Room, Seed
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
|
||||
@@ -32,17 +32,18 @@ def room_info(room: UUID):
|
||||
|
||||
@api_endpoints.route('/datapackage')
|
||||
@cache.cached()
|
||||
def get_datapackge():
|
||||
def get_datapackage():
|
||||
from worlds import network_data_package
|
||||
return network_data_package
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage_version')
|
||||
@cache.cached()
|
||||
def get_datapackge_versions():
|
||||
|
||||
def get_datapackage_versions():
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
|
||||
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||
version_package["version"] = network_data_package["version"]
|
||||
return version_package
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import json
|
||||
import pickle
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from . import api_endpoints
|
||||
from flask import request, session, url_for
|
||||
from flask import request, session, url_for, Markup
|
||||
from pony.orm import commit
|
||||
|
||||
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||
from WebHostLib import app
|
||||
from WebHostLib.check import get_yaml_data, roll_options
|
||||
from WebHostLib.generate import get_meta
|
||||
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||
from . import api_endpoints
|
||||
|
||||
|
||||
@api_endpoints.route('/generate', methods=['POST'])
|
||||
@@ -21,13 +21,18 @@ def generate_api():
|
||||
if 'file' in request.files:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
if isinstance(options, Markup):
|
||||
return {"text": options.striptags()}, 400
|
||||
if isinstance(options, str):
|
||||
return {"text": options}, 400
|
||||
if "race" in request.form:
|
||||
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
|
||||
meta_options_source = request.form
|
||||
|
||||
json_data = 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:
|
||||
meta_options_source = json_data
|
||||
if 'weights' in json_data:
|
||||
@@ -45,7 +50,7 @@ def generate_api():
|
||||
"detail": app.config["MAX_ROLL"]}, 409
|
||||
meta = get_meta(meta_options_source)
|
||||
meta["race"] = race
|
||||
results, gen_options = roll_options(options)
|
||||
results, gen_options = roll_options(options, meta["plando_options"])
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return {"text": str(results),
|
||||
"detail": results}, 400
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
from datetime import timedelta, datetime
|
||||
import concurrent.futures
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from pony.orm import db_session, select, commit
|
||||
|
||||
@@ -17,6 +18,7 @@ from Utils import restricted_loads
|
||||
class CommonLocker():
|
||||
"""Uses a file lock to signal that something is already running"""
|
||||
lock_folder = "file_locks"
|
||||
|
||||
def __init__(self, lockname: str, folder=None):
|
||||
if folder:
|
||||
self.lock_folder = folder
|
||||
@@ -53,7 +55,7 @@ else: # unix
|
||||
def __enter__(self):
|
||||
try:
|
||||
self.fp = open(self.lockfile, "wb")
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX)
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
@@ -110,6 +112,7 @@ def autohost(config: dict):
|
||||
def keep_running():
|
||||
try:
|
||||
with Locker("autohost"):
|
||||
run_guardian()
|
||||
while 1:
|
||||
time.sleep(0.1)
|
||||
with db_session:
|
||||
@@ -151,8 +154,10 @@ def autogen(config: dict):
|
||||
while 1:
|
||||
time.sleep(0.1)
|
||||
with db_session:
|
||||
# for update locks the database row(s) during transaction, preventing writes from elsewhere
|
||||
to_start = select(
|
||||
generation for generation in Generation if generation.state == STATE_QUEUED)
|
||||
generation for generation in Generation
|
||||
if generation.state == STATE_QUEUED).for_update()
|
||||
for generation in to_start:
|
||||
launch_generator(generator_pool, generation)
|
||||
except AlreadyRunningException:
|
||||
@@ -162,40 +167,80 @@ def autogen(config: dict):
|
||||
threading.Thread(target=keep_running, name="AP_Autogen").start()
|
||||
|
||||
|
||||
multiworlds = {}
|
||||
|
||||
guardians = concurrent.futures.ThreadPoolExecutor(2, thread_name_prefix="Guardian")
|
||||
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
||||
|
||||
|
||||
class MultiworldInstance():
|
||||
def __init__(self, room: Room, config: dict):
|
||||
self.room_id = room.id
|
||||
self.process: typing.Optional[multiprocessing.Process] = None
|
||||
multiworlds[self.room_id] = self
|
||||
with guardian_lock:
|
||||
multiworlds[self.room_id] = self
|
||||
self.ponyconfig = config["PONY"]
|
||||
self.cert = config["SELFLAUNCHCERT"]
|
||||
self.key = config["SELFLAUNCHKEY"]
|
||||
self.host = config["HOST_ADDRESS"]
|
||||
|
||||
def start(self):
|
||||
if self.process and self.process.is_alive():
|
||||
return False
|
||||
|
||||
logging.info(f"Spinning up {self.room_id}")
|
||||
self.process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.room_id, self.ponyconfig),
|
||||
name="MultiHost")
|
||||
self.process.start()
|
||||
self.guardian = guardians.submit(self._collect)
|
||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.room_id, self.ponyconfig, get_static_server_data(),
|
||||
self.cert, self.key, self.host),
|
||||
name="MultiHost")
|
||||
process.start()
|
||||
# bind after start to prevent thread sync issues with guardian.
|
||||
self.process = process
|
||||
|
||||
def stop(self):
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
self.process = None
|
||||
|
||||
def _collect(self):
|
||||
def done(self):
|
||||
return self.process and not self.process.is_alive()
|
||||
|
||||
def collect(self):
|
||||
self.process.join() # wait for process to finish
|
||||
self.process = None
|
||||
self.guardian = None
|
||||
|
||||
|
||||
guardian = None
|
||||
guardian_lock = threading.Lock()
|
||||
|
||||
|
||||
def run_guardian():
|
||||
global guardian
|
||||
global multiworlds
|
||||
with guardian_lock:
|
||||
if not guardian:
|
||||
try:
|
||||
import resource
|
||||
except ModuleNotFoundError:
|
||||
pass # unix only module
|
||||
else:
|
||||
# Each Server is another file handle, so request as many as we can from the system
|
||||
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
|
||||
# set soft limit to hard limit
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
|
||||
|
||||
def guard():
|
||||
while 1:
|
||||
time.sleep(1)
|
||||
done = []
|
||||
with guardian_lock:
|
||||
for key, instance in multiworlds.items():
|
||||
if instance.done():
|
||||
instance.collect()
|
||||
done.append(key)
|
||||
for key in done:
|
||||
del (multiworlds[key])
|
||||
|
||||
guardian = threading.Thread(name="Guardian", target=guard)
|
||||
|
||||
|
||||
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
|
||||
from .customserver import run_server_process
|
||||
from .customserver import run_server_process, get_static_server_data
|
||||
from .generate import gen_game
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import zipfile
|
||||
from typing import *
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
from flask import request, flash, redirect, url_for, render_template, Markup
|
||||
|
||||
from WebHostLib import app
|
||||
|
||||
@@ -12,12 +12,12 @@ def allowed_file(filename):
|
||||
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||
|
||||
|
||||
from Generate import roll_settings
|
||||
from Utils import parse_yaml
|
||||
from Generate import roll_settings, PlandoOptions
|
||||
from Utils import parse_yamls
|
||||
|
||||
|
||||
@app.route('/mysterycheck', methods=['GET', 'POST'])
|
||||
def mysterycheck():
|
||||
@app.route('/check', methods=['GET', 'POST'])
|
||||
def check():
|
||||
if request.method == 'POST':
|
||||
# check if the post request has the file part
|
||||
if 'file' not in request.files:
|
||||
@@ -25,16 +25,20 @@ def mysterycheck():
|
||||
else:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
if isinstance(options, str):
|
||||
flash(options)
|
||||
else:
|
||||
results, _ = roll_options(options)
|
||||
return render_template("checkResult.html", results=results)
|
||||
|
||||
return render_template("check.html")
|
||||
|
||||
|
||||
def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
@app.route('/mysterycheck')
|
||||
def mysterycheck():
|
||||
return redirect(url_for("check"), 301)
|
||||
|
||||
|
||||
def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
|
||||
options = {}
|
||||
# if user does not select file, browser also
|
||||
# submit an empty part without filename
|
||||
@@ -46,6 +50,10 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
with zipfile.ZipFile(file, 'r') as zfile:
|
||||
infolist = zfile.infolist()
|
||||
|
||||
if any(file.filename.endswith(".archipelago") for file in infolist):
|
||||
return Markup("Error: Your .zip file contains an .archipelago file. "
|
||||
'Did you mean to <a href="/uploads">host a game</a>?')
|
||||
|
||||
for file in infolist:
|
||||
if file.filename.endswith(banned_zip_contents):
|
||||
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted."
|
||||
@@ -58,21 +66,29 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
return options
|
||||
|
||||
|
||||
def roll_options(options: Dict[str, Union[dict, str]]) -> Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||
def roll_options(options: Dict[str, Union[dict, str]],
|
||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||
plando_options = PlandoOptions.from_set(set(plando_options))
|
||||
results = {}
|
||||
rolled_results = {}
|
||||
for filename, text in options.items():
|
||||
try:
|
||||
if type(text) is dict:
|
||||
yaml_data = text
|
||||
yaml_datas = (text, )
|
||||
else:
|
||||
yaml_data = parse_yaml(text)
|
||||
yaml_datas = tuple(parse_yamls(text))
|
||||
except Exception as e:
|
||||
results[filename] = f"Failed to parse YAML data in {filename}: {e}"
|
||||
else:
|
||||
try:
|
||||
rolled_results[filename] = roll_settings(yaml_data,
|
||||
plando_options={"bosses", "items", "connections", "texts"})
|
||||
if len(yaml_datas) == 1:
|
||||
rolled_results[filename] = roll_settings(yaml_datas[0],
|
||||
plando_options=plando_options)
|
||||
else:
|
||||
for i, yaml_data in enumerate(yaml_datas):
|
||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||
plando_options=plando_options)
|
||||
except Exception as e:
|
||||
results[filename] = f"Failed to generate mystery in {filename}: {e}"
|
||||
else:
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import datetime
|
||||
import functools
|
||||
import logging
|
||||
import websockets
|
||||
import asyncio
|
||||
import pickle
|
||||
import random
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import random
|
||||
import pickle
|
||||
import typing
|
||||
|
||||
import websockets
|
||||
from pony.orm import commit, db_session, select
|
||||
|
||||
import Utils
|
||||
from .models import *
|
||||
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
||||
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
|
||||
from .models import Room, Command, db
|
||||
|
||||
|
||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
@@ -39,7 +44,7 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
import MultiServer
|
||||
|
||||
MultiServer.client_message_processor = CustomClientMessageProcessor
|
||||
del (MultiServer)
|
||||
del MultiServer
|
||||
|
||||
|
||||
class DBCommandProcessor(ServerCommandProcessor):
|
||||
@@ -48,12 +53,23 @@ class DBCommandProcessor(ServerCommandProcessor):
|
||||
|
||||
|
||||
class WebHostContext(Context):
|
||||
def __init__(self):
|
||||
room_id: int
|
||||
|
||||
def __init__(self, static_server_data: dict):
|
||||
# static server data is used during _load_game_data to load required data,
|
||||
# without needing to import worlds system, which takes quite a bit of memory
|
||||
self.static_server_data = static_server_data
|
||||
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
|
||||
del self.static_server_data
|
||||
self.main_loop = asyncio.get_running_loop()
|
||||
self.video = {}
|
||||
self.tags = ["AP", "WebHost"]
|
||||
|
||||
def _load_game_data(self):
|
||||
for key, value in self.static_server_data.items():
|
||||
setattr(self, key, value)
|
||||
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||
|
||||
def listen_to_db_commands(self):
|
||||
cmdprocessor = DBCommandProcessor(self)
|
||||
|
||||
@@ -94,7 +110,7 @@ class WebHostContext(Context):
|
||||
room.multisave = pickle.dumps(self.get_save())
|
||||
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||
room.last_activity = datetime.utcnow()
|
||||
room.last_activity = datetime.datetime.utcnow()
|
||||
return True
|
||||
|
||||
def get_save(self) -> dict:
|
||||
@@ -107,36 +123,63 @@ def get_random_port():
|
||||
return random.randint(49152, 65535)
|
||||
|
||||
|
||||
def run_server_process(room_id, ponyconfig: dict):
|
||||
@cache_argsless
|
||||
def get_static_server_data() -> dict:
|
||||
import worlds
|
||||
data = {
|
||||
"non_hintable_names": {},
|
||||
"gamespackage": worlds.network_data_package["games"],
|
||||
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()},
|
||||
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()},
|
||||
}
|
||||
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
data["non_hintable_names"][world_name] = world.hint_blacklist
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
|
||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||
host: str):
|
||||
# establish DB connection for multidata and multisave
|
||||
db.bind(**ponyconfig)
|
||||
db.generate_mapping(check_tables=False)
|
||||
|
||||
async def main():
|
||||
Utils.init_logging(str(room_id), write_mode="a")
|
||||
ctx = WebHostContext()
|
||||
ctx = WebHostContext(static_server_data)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
|
||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
||||
try:
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
|
||||
ping_interval=None)
|
||||
ping_interval=None, ssl=ssl_context)
|
||||
|
||||
await ctx.server
|
||||
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None,
|
||||
ping_interval=None)
|
||||
ping_interval=None, ssl=ssl_context)
|
||||
|
||||
await ctx.server
|
||||
port = 0
|
||||
for wssocket in ctx.server.ws_server.sockets:
|
||||
socketname = wssocket.getsockname()
|
||||
if wssocket.family == socket.AF_INET6:
|
||||
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = socketname[1]
|
||||
# Prefer IPv4, as most users seem to not have working ipv6 support
|
||||
if not port:
|
||||
port = socketname[1]
|
||||
elif wssocket.family == socket.AF_INET:
|
||||
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
|
||||
port = socketname[1]
|
||||
if port:
|
||||
logging.info(f'Hosting game at {host}:{port}')
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = port
|
||||
else:
|
||||
logging.exception("Could not determine port. Likely hosting failure.")
|
||||
with db_session:
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
@@ -145,7 +188,12 @@ def run_server_process(room_id, ponyconfig: dict):
|
||||
|
||||
from .autolauncher import Locker
|
||||
with Locker(room_id):
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
from WebHostLib import LOGS_FOLDER
|
||||
try:
|
||||
asyncio.run(main())
|
||||
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,9 +1,13 @@
|
||||
import json
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
|
||||
from flask import send_file, Response, render_template
|
||||
from pony.orm import select
|
||||
|
||||
from Patch import update_patch_data, preferred_endings
|
||||
from WebHostLib import app, Slot, Room, Seed, cache
|
||||
import zipfile
|
||||
from worlds.Files import AutoPatchRegister
|
||||
from . import app, cache
|
||||
from .models import Slot, Room, Seed
|
||||
|
||||
|
||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||
@@ -12,17 +16,33 @@ def download_patch(room_id, patch_id):
|
||||
if not patch:
|
||||
return "Patch not found"
|
||||
else:
|
||||
import io
|
||||
|
||||
room = Room.get(id=room_id)
|
||||
last_port = room.last_port
|
||||
|
||||
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
||||
patch_data = io.BytesIO(patch_data)
|
||||
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
||||
f"{preferred_endings[patch.game]}"
|
||||
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
|
||||
filelike = BytesIO(patch.data)
|
||||
greater_than_version_3 = zipfile.is_zipfile(filelike)
|
||||
if greater_than_version_3:
|
||||
# Python's zipfile module cannot overwrite/delete files in a zip, so we recreate the whole thing in ram
|
||||
new_file = BytesIO()
|
||||
with zipfile.ZipFile(filelike, "a") as zf:
|
||||
with zf.open("archipelago.json", "r") as f:
|
||||
manifest = json.load(f)
|
||||
manifest["server"] = f"{app.config['HOST_ADDRESS']}:{last_port}" if last_port else None
|
||||
with zipfile.ZipFile(new_file, "w") as new_zip:
|
||||
for file in zf.infolist():
|
||||
if file.filename == "archipelago.json":
|
||||
new_zip.writestr("archipelago.json", json.dumps(manifest))
|
||||
else:
|
||||
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
|
||||
if "patch_file_ending" in manifest:
|
||||
patch_file_ending = manifest["patch_file_ending"]
|
||||
else:
|
||||
patch_file_ending = AutoPatchRegister.patch_types[patch.game].patch_file_ending
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \
|
||||
f"{patch_file_ending}"
|
||||
new_file.seek(0)
|
||||
return send_file(new_file, as_attachment=True, download_name=fname)
|
||||
else:
|
||||
return "Old Patch file, no longer compatible."
|
||||
|
||||
|
||||
@app.route("/dl_spoiler/<suuid:seed_id>")
|
||||
@@ -34,7 +54,7 @@ def download_spoiler(seed_id):
|
||||
def download_slot_file(room_id, player_id: int):
|
||||
room = Room.get(id=room_id)
|
||||
slot_data: Slot = select(patch for patch in room.seed.slots if
|
||||
patch.player_id == player_id).first()
|
||||
patch.player_id == player_id).first()
|
||||
|
||||
if not slot_data:
|
||||
return "Slot Data not found"
|
||||
@@ -44,18 +64,34 @@ def download_slot_file(room_id, player_id: int):
|
||||
if slot_data.game == "Minecraft":
|
||||
from worlds.minecraft import mc_update_output
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
||||
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
|
||||
return send_file(io.BytesIO(data), as_attachment=True, attachment_filename=fname)
|
||||
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
|
||||
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
|
||||
elif slot_data.game == "Factorio":
|
||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith("info.json"):
|
||||
fname = name.rsplit("/", 1)[0]+".zip"
|
||||
fname = name.rsplit("/", 1)[0] + ".zip"
|
||||
elif slot_data.game == "Ocarina of Time":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||
stream = io.BytesIO(slot_data.data)
|
||||
if zipfile.is_zipfile(stream):
|
||||
with zipfile.ZipFile(stream) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith(".zpf"):
|
||||
fname = name.rsplit(".", 1)[0] + ".apz5"
|
||||
else: # pre-ootr-7.0 support
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||
elif slot_data.game == "VVVVVV":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
||||
elif slot_data.game == "Zillion":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
|
||||
elif slot_data.game == "Super Mario 64":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
||||
elif slot_data.game == "Dark Souls III":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
|
||||
else:
|
||||
return "Game download not supported."
|
||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
|
||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
|
||||
|
||||
|
||||
@app.route("/templates")
|
||||
@cache.cached()
|
||||
@@ -65,4 +101,4 @@ def list_yaml_templates():
|
||||
for world_name, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
files.append(world_name)
|
||||
return render_template("templates.html", files=files)
|
||||
return render_template("templates.html", files=files)
|
||||
|
||||
@@ -1,34 +1,45 @@
|
||||
import os
|
||||
import tempfile
|
||||
import random
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import random
|
||||
import tempfile
|
||||
import zipfile
|
||||
import concurrent.futures
|
||||
from collections import Counter
|
||||
from typing import Dict, Optional as TypeOptional
|
||||
from Utils import __version__
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
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 Generate import handle_name
|
||||
import pickle
|
||||
|
||||
from .models import *
|
||||
from Generate import handle_name, PlandoOptions
|
||||
from Main import main as ERmain
|
||||
from Utils import __version__
|
||||
from WebHostLib import app
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from .check import get_yaml_data, roll_options
|
||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
||||
from .upload import upload_zip_to_db
|
||||
|
||||
|
||||
def get_meta(options_source: dict) -> dict:
|
||||
meta = {
|
||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
|
||||
"remaining_mode": options_source.get("forfeit_mode", "disabled"),
|
||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||
plando_options = {
|
||||
options_source.get("plando_bosses", ""),
|
||||
options_source.get("plando_items", ""),
|
||||
options_source.get("plando_connections", ""),
|
||||
options_source.get("plando_texts", "")
|
||||
}
|
||||
return meta
|
||||
plando_options -= {""}
|
||||
|
||||
server_options = {
|
||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||
"release_mode": options_source.get("release_mode", "goal"),
|
||||
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||
"server_password": options_source.get("server_password", None),
|
||||
}
|
||||
return {"server_options": server_options, "plando_options": list(plando_options)}
|
||||
|
||||
|
||||
@app.route('/generate', methods=['GET', 'POST'])
|
||||
@@ -41,22 +52,21 @@ def generate(race=False):
|
||||
else:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
if isinstance(options, str):
|
||||
flash(options)
|
||||
else:
|
||||
results, gen_options = roll_options(options)
|
||||
# get form data -> server settings
|
||||
meta = get_meta(request.form)
|
||||
meta["race"] = race
|
||||
results, gen_options = roll_options(options, meta["plando_options"])
|
||||
|
||||
if race:
|
||||
meta["item_cheat"] = False
|
||||
meta["remaining"] = False
|
||||
meta["server_options"]["item_cheat"] = False
|
||||
meta["server_options"]["remaining_mode"] = "disabled"
|
||||
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return render_template("checkResult.html", results=results)
|
||||
elif len(gen_options) > app.config["MAX_ROLL"]:
|
||||
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players for now. "
|
||||
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
|
||||
f"If you have a larger group, please generate it yourself and upload it.")
|
||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||
gen = Generation(
|
||||
@@ -82,32 +92,35 @@ def generate(race=False):
|
||||
return render_template("generate.html", race=race, version=__version__)
|
||||
|
||||
|
||||
def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=None, sid=None):
|
||||
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||
if not meta:
|
||||
meta: Dict[str, object] = {}
|
||||
meta: Dict[str, Any] = {}
|
||||
|
||||
meta.setdefault("hint_cost", 10)
|
||||
race = meta.get("race", False)
|
||||
del (meta["race"])
|
||||
try:
|
||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||
race = meta.setdefault("race", False)
|
||||
|
||||
def task():
|
||||
target = tempfile.TemporaryDirectory()
|
||||
playercount = len(gen_options)
|
||||
seed = get_seed()
|
||||
random.seed(seed)
|
||||
|
||||
if race:
|
||||
random.seed() # reset to time-based random source
|
||||
random.seed() # use time-based random source
|
||||
else:
|
||||
random.seed(seed)
|
||||
|
||||
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
||||
|
||||
erargs = parse_arguments(['--multi', str(playercount)])
|
||||
erargs.seed = seed
|
||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwrittin in mystery
|
||||
erargs.spoiler = 0 if race else 2
|
||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||
erargs.spoiler = 0 if race else 3
|
||||
erargs.race = race
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
|
||||
name_counter = Counter()
|
||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||
@@ -123,9 +136,26 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
if len(set(erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||
ERmain(erargs, seed, baked_server_options=meta)
|
||||
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
||||
|
||||
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:
|
||||
if sid:
|
||||
with db_session:
|
||||
@@ -135,7 +165,6 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
|
||||
meta = json.loads(gen.meta)
|
||||
meta["error"] = (e.__class__.__name__ + ": " + str(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
|
||||
commit()
|
||||
raise
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from flask import render_template
|
||||
from pony.orm import count
|
||||
|
||||
from WebHostLib import app, cache
|
||||
from .models import *
|
||||
from datetime import timedelta
|
||||
from .models import Room, Seed
|
||||
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import threading
|
||||
import json
|
||||
|
||||
from Utils import local_path
|
||||
from Utils import local_path, user_path
|
||||
from worlds.alttp.Rom import Sprite
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ def update_sprites_lttp():
|
||||
from LttPAdjuster import update_sprites
|
||||
|
||||
# Target directories
|
||||
input_dir = local_path("data", "sprites", "alttpr")
|
||||
output_dir = local_path("WebHostLib", "static", "generated")
|
||||
input_dir = user_path("data", "sprites", "alttpr")
|
||||
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
||||
|
||||
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
||||
# update sprites through gui.py's functions
|
||||
@@ -32,7 +32,7 @@ def update_sprites_lttp():
|
||||
|
||||
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))
|
||||
|
||||
if not sprite.name:
|
||||
|
||||
172
WebHostLib/misc.py
Normal file
@@ -0,0 +1,172 @@
|
||||
import datetime
|
||||
import os
|
||||
from typing import List, Dict, Union
|
||||
|
||||
import jinja2.exceptions
|
||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||
from pony.orm import count, commit, db_session
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import app, cache
|
||||
from .models import Seed, Room, Command, UUID, uuid4
|
||||
|
||||
|
||||
def get_world_theme(game_name: str):
|
||||
if game_name in AutoWorldRegister.world_types:
|
||||
return AutoWorldRegister.world_types[game_name].web.theme
|
||||
return 'grass'
|
||||
|
||||
|
||||
@app.before_request
|
||||
def register_session():
|
||||
session.permanent = True # technically 31 days after the last visit
|
||||
if not session.get("_id", None):
|
||||
session["_id"] = uuid4() # uniquely identify each session without needing a login
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
||||
def page_not_found(err):
|
||||
return render_template('404.html'), 404
|
||||
|
||||
|
||||
# Start Playing Page
|
||||
@app.route('/start-playing')
|
||||
def start_playing():
|
||||
return render_template(f"startPlaying.html")
|
||||
|
||||
|
||||
@app.route('/weighted-settings')
|
||||
def weighted_settings():
|
||||
return render_template(f"weighted-settings.html")
|
||||
|
||||
|
||||
# Player settings pages
|
||||
@app.route('/games/<string:game>/player-settings')
|
||||
def player_settings(game):
|
||||
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
|
||||
|
||||
|
||||
# Game Info Pages
|
||||
@app.route('/games/<string:game>/info/<string:lang>')
|
||||
def game_info(game, lang):
|
||||
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
||||
|
||||
|
||||
# List of supported games
|
||||
@app.route('/games')
|
||||
def games():
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world
|
||||
return render_template("supportedGames.html", worlds=worlds)
|
||||
|
||||
|
||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||
def tutorial(game, file, lang):
|
||||
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
||||
|
||||
|
||||
@app.route('/tutorial/')
|
||||
def tutorial_landing():
|
||||
return render_template("tutorialLanding.html")
|
||||
|
||||
|
||||
@app.route('/faq/<string:lang>/')
|
||||
def faq(lang):
|
||||
return render_template("faq.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/glossary/<string:lang>/')
|
||||
def terms(lang):
|
||||
return render_template("glossary.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/seed/<suuid:seed>')
|
||||
def view_seed(seed: UUID):
|
||||
seed = Seed.get(id=seed)
|
||||
if not seed:
|
||||
abort(404)
|
||||
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
|
||||
|
||||
|
||||
@app.route('/new_room/<suuid:seed>')
|
||||
def new_room(seed: UUID):
|
||||
seed = Seed.get(id=seed)
|
||||
if not seed:
|
||||
abort(404)
|
||||
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
|
||||
commit()
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
|
||||
def _read_log(path: str):
|
||||
if os.path.exists(path):
|
||||
with open(path, encoding="utf-8-sig") as log:
|
||||
yield from log
|
||||
else:
|
||||
yield f"Logfile {path} does not exist. " \
|
||||
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
|
||||
|
||||
|
||||
@app.route('/log/<suuid:room>')
|
||||
def display_log(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if room.owner == session["_id"]:
|
||||
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||
return "Access Denied", 403
|
||||
|
||||
|
||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||
def host_room(room: UUID):
|
||||
room: Room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if request.method == "POST":
|
||||
if room.owner == session["_id"]:
|
||||
cmd = request.form["cmd"]
|
||||
if cmd:
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
# indicate that the page should reload to get the assigned port
|
||||
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
|
||||
with db_session:
|
||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||
|
||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
|
||||
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
return send_from_directory(os.path.join(app.root_path, 'static/static'),
|
||||
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
||||
|
||||
|
||||
@app.route('/discord')
|
||||
def discord():
|
||||
return redirect("https://discord.gg/8Z65BR2")
|
||||
|
||||
|
||||
@app.route('/datapackage')
|
||||
@cache.cached()
|
||||
def get_datapackage():
|
||||
"""A pretty print version of /api/datapackage"""
|
||||
from worlds import network_data_package
|
||||
import json
|
||||
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
|
||||
|
||||
|
||||
@app.route('/index')
|
||||
@app.route('/sitemap')
|
||||
def get_sitemap():
|
||||
available_games: List[Dict[str, Union[str, bool]]] = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
has_settings: bool = isinstance(world.web.settings_page, bool) and world.web.settings_page
|
||||
available_games.append({ 'title': game, 'has_settings': has_settings })
|
||||
return render_template("siteMap.html", games=available_games)
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
from pony.orm import *
|
||||
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
|
||||
|
||||
db = Database()
|
||||
|
||||
@@ -12,7 +12,7 @@ STATE_ERROR = -1
|
||||
class Slot(db.Entity):
|
||||
id = PrimaryKey(int, auto=True)
|
||||
player_id = Required(int)
|
||||
player_name = Required(str, 16)
|
||||
player_name = Required(str)
|
||||
data = Optional(bytes, lazy=True)
|
||||
seed = Optional('Seed')
|
||||
game = Required(str)
|
||||
@@ -27,8 +27,9 @@ class Room(db.Entity):
|
||||
seed = Required('Seed', index=True)
|
||||
multisave = Optional(buffer, lazy=True)
|
||||
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
|
||||
timeout = Required(int, default=lambda: 6 * 60 * 60) # seconds since last activity to shutdown
|
||||
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
|
||||
tracker = Optional(UUID, index=True)
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
@@ -1,35 +1,50 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from Utils import __version__
|
||||
from jinja2 import Template
|
||||
import typing
|
||||
|
||||
import yaml
|
||||
import json
|
||||
from jinja2 import Template
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import Options
|
||||
|
||||
target_folder = os.path.join("WebHostLib", "static", "generated")
|
||||
from Utils import __version__, local_path
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||
"exclude_locations"}
|
||||
|
||||
|
||||
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")
|
||||
os.makedirs(yaml_folder, exist_ok=True)
|
||||
|
||||
for file in os.listdir(yaml_folder):
|
||||
full_path: str = os.path.join(yaml_folder, file)
|
||||
if os.path.isfile(full_path):
|
||||
os.unlink(full_path)
|
||||
|
||||
def dictify_range(option: typing.Union[Options.Range, Options.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
|
||||
|
||||
def dictify_range(option):
|
||||
data = {option.range_start: 0, option.range_end: 0, "random": 0, "random-low": 0, "random-high": 0,
|
||||
option.default: 50}
|
||||
notes = {
|
||||
option.range_start: "minimum value",
|
||||
option.range_end: "maximum value"
|
||||
}
|
||||
return data, notes
|
||||
|
||||
def default_converter(default_value):
|
||||
if isinstance(default_value, (set, frozenset)):
|
||||
return list(default_value)
|
||||
return default_value
|
||||
def get_html_doc(option_type: type(Options.Option)) -> str:
|
||||
if not option_type.__doc__:
|
||||
return "Please document me!"
|
||||
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
|
||||
|
||||
weighted_settings = {
|
||||
"baseOptions": {
|
||||
@@ -42,14 +57,21 @@ def create():
|
||||
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
|
||||
all_options = {**world.options, **Options.per_game_common_options}
|
||||
res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
|
||||
all_options: typing.Dict[str, Options.AssembleOptions] = {
|
||||
**Options.per_game_common_options,
|
||||
**world.option_definitions
|
||||
}
|
||||
with open(local_path("WebHostLib", "templates", "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, default_converter=default_converter,
|
||||
dictify_range=dictify_range,
|
||||
)
|
||||
|
||||
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
|
||||
del file_data
|
||||
|
||||
with open(os.path.join(target_folder, "configs", game_name + ".yaml"), "w", encoding="utf-8") as f:
|
||||
f.write(res)
|
||||
|
||||
# Generate JSON files for player-settings pages
|
||||
@@ -70,7 +92,7 @@ def create():
|
||||
game_options[option_name] = this_option = {
|
||||
"type": "select",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": None,
|
||||
"options": []
|
||||
}
|
||||
@@ -84,41 +106,46 @@ def create():
|
||||
if sub_option_id == option.default:
|
||||
this_option["defaultValue"] = sub_option_name
|
||||
|
||||
this_option["options"].append({
|
||||
"name": "Random",
|
||||
"value": "random",
|
||||
})
|
||||
if option.default == "random":
|
||||
this_option["defaultValue"] = "random"
|
||||
|
||||
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
|
||||
elif issubclass(option, Options.Range):
|
||||
game_options[option_name] = {
|
||||
"type": "range",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"defaultValue": option.default if hasattr(option, "default") else option.range_start,
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": option.default if hasattr(
|
||||
option, "default") and option.default != "random" else option.range_start,
|
||||
"min": option.range_start,
|
||||
"max": option.range_end,
|
||||
}
|
||||
|
||||
if issubclass(option, Options.SpecialRange):
|
||||
game_options[option_name]["type"] = 'special_range'
|
||||
game_options[option_name]["value_names"] = {}
|
||||
for key, val in option.special_range_names.items():
|
||||
game_options[option_name]["value_names"][key] = val
|
||||
|
||||
elif getattr(option, "verify_item_name", False):
|
||||
game_options[option_name] = {
|
||||
"type": "items-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"description": get_html_doc(option),
|
||||
}
|
||||
|
||||
elif getattr(option, "verify_location_name", False):
|
||||
game_options[option_name] = {
|
||||
"type": "locations-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"description": get_html_doc(option),
|
||||
}
|
||||
|
||||
elif hasattr(option, "valid_keys"):
|
||||
elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet):
|
||||
if option.valid_keys:
|
||||
game_options[option_name] = {
|
||||
"type": "custom-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"description": get_html_doc(option),
|
||||
"options": list(option.valid_keys),
|
||||
}
|
||||
|
||||
@@ -132,7 +159,7 @@ def create():
|
||||
with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
|
||||
json.dump(player_settings, f, indent=2, separators=(',', ': '))
|
||||
|
||||
if not world.hidden:
|
||||
if not world.hidden and world.web.settings_page is True:
|
||||
weighted_settings["baseOptions"]["game"][game_name] = 0
|
||||
weighted_settings["games"][game_name] = {}
|
||||
weighted_settings["games"][game_name]["gameSettings"] = game_options
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
flask>=2.0.3
|
||||
flask>=2.2.3
|
||||
pony>=0.7.16
|
||||
waitress>=2.0.0
|
||||
flask-caching>=1.10.1
|
||||
Flask-Compress>=1.10.1
|
||||
Flask-Limiter>=2.1.3
|
||||
waitress>=2.1.2
|
||||
Flask-Caching>=2.0.2
|
||||
Flask-Compress>=1.13
|
||||
Flask-Limiter>=3.3.0
|
||||
bokeh>=3.1.0
|
||||
|
||||
@@ -4,6 +4,7 @@ window.addEventListener('load', () => {
|
||||
"ordering": true,
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
console.log(tables);
|
||||
});
|
||||
|
||||
18
WebHostLib/static/assets/baseHeader.js
Normal file
@@ -0,0 +1,18 @@
|
||||
window.addEventListener('load', () => {
|
||||
const menuButton = document.getElementById('base-header-mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('base-header-mobile-menu');
|
||||
|
||||
menuButton.addEventListener('click', (evt) => {
|
||||
evt.preventDefault();
|
||||
|
||||
if (!mobileMenu.style.display || mobileMenu.style.display === 'none') {
|
||||
return mobileMenu.style.display = 'flex';
|
||||
}
|
||||
|
||||
mobileMenu.style.display = 'none';
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
mobileMenu.style.display = 'none';
|
||||
});
|
||||
});
|
||||
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();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
|
||||
@@ -20,7 +20,7 @@ comfortable exploiting certain glitches in the game.
|
||||
## What is a multi-world?
|
||||
|
||||
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a
|
||||
two player multi-world, players A and B each get their own randomized version of a game, called seeds. In each player's
|
||||
two player multi-world, players A and B each get their own randomized version of a game, called a world. In each player's
|
||||
game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the
|
||||
item will be sent to player B's world over the internet.
|
||||
|
||||
@@ -29,7 +29,7 @@ their game.
|
||||
|
||||
## What happens if a person has to leave early?
|
||||
|
||||
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all the
|
||||
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all the
|
||||
items in that game which belong to other players are sent out automatically, so other players can continue to play.
|
||||
|
||||
## What does multi-game mean?
|
||||
@@ -46,9 +46,15 @@ the website is not required to generate them.
|
||||
## How do I get started?
|
||||
|
||||
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
|
||||
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
|
||||
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
|
||||
any questions you might have.
|
||||
|
||||
## What are some common terms I should know?
|
||||
|
||||
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
|
||||
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
|
||||
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
|
||||
|
||||
## I want to add a game to the Archipelago randomizer. How do I do that?
|
||||
|
||||
The best way to get started is to take a look at our code on GitHub
|
||||
|
||||
94
WebHostLib/static/assets/faq/glossary_en.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Multiworld Glossary
|
||||
|
||||
There are a lot of common terms used when playing in different game randomizer communities and in multiworld as well.
|
||||
This document serves as a lookup for common terms that may be used by users in the community or in various other
|
||||
documentation.
|
||||
|
||||
## Item
|
||||
Items are what get shuffled around in your world or other worlds that you then receive. This could be a sword, a stat
|
||||
upgrade, a spell, or any other potential receivable for your game.
|
||||
|
||||
## Location
|
||||
Locations are where items are placed in your game. Whenever you interact with a location, you or another player will
|
||||
then receive an item. A location could be a chest, an enemy drop, a shop purchase, or any other interactable that can
|
||||
contain items in your game.
|
||||
|
||||
## Check
|
||||
A check is a common term for when you "check", or pick up, a location. In terms of Archipelago this is usually used for
|
||||
when a player goes to a location and sends its item, or "checks" the location. Players will often reference their now
|
||||
randomized locations as checks.
|
||||
|
||||
## Slot
|
||||
A slot is the player name and number assigned during generation. The number of slots is equal to the number of players,
|
||||
or "worlds", created. Each name must be unique as these are used to identify the slot user.
|
||||
|
||||
## World
|
||||
World in terms of Archipelago can mean multiple things and is used interchangeably in many situations.
|
||||
* During gameplay, a world is a single instance of a game, occupying one player "slot". However,
|
||||
Archipelago allows multiple players to connect to the same slot; then those players can share a world
|
||||
and complete it cooperatively. For games with native cooperative play, you can also play together and
|
||||
share a world that way, usually with only one player connected to the multiworld.
|
||||
* On the programming side, a world typically represents the package that integrates Archipelago with a
|
||||
particular game. For example this could be the entire `worlds/factorio` directory.
|
||||
|
||||
## RNG
|
||||
Acronym for "Random Number Generator." Archipelago uses its own custom Random object with a unique seed per generation,
|
||||
or, if running from source, a seed can be supplied and this seed will control all randomization during generation as all
|
||||
game worlds will have access to it.
|
||||
|
||||
## Seed
|
||||
A "seed" is a number used to initialize a pseudorandom number generator. Whenever you generate a new game on Archipelago
|
||||
this is a new "seed" as it has unique item placement, and you can create multiple "rooms" on the Archipelago site from a
|
||||
single seed. Using the same seed results in the random placement being the same.
|
||||
|
||||
## Room
|
||||
Whenever you generate a seed on the Archipelago website you will be put on a seed page that contains all the seed info
|
||||
with a link to the spoiler if one exists and will show how many unique rooms exist per seed. Each room has its own
|
||||
unique identifier that is separate from the seed. The room page is where you can find information to connect to the
|
||||
multiworld and download any patches if necessary. If you have a particularly fun or interesting seed, and you want to
|
||||
share it with somebody you can link them to this seed page, where they can generate a new room to play it! For seeds
|
||||
generated with race mode enabled, the seed page will only show rooms created by the unique user so the seed page is
|
||||
perfectly safe to share for racing purposes.
|
||||
|
||||
## Logic
|
||||
Base behavior of all seeds generated by Archipelago is they are expected to be completable based on the requirements of
|
||||
the settings. This is done by using "logic" in order to determine valid locations to place items while still being able
|
||||
to reach said location without this item. For the purposes of the randomizer a location is considered "in logic" if you
|
||||
can reach it with your current toolset of items or skills based on settings. Some players are able to obtain locations
|
||||
"out of logic" by performing various glitches or tricks that the settings may not account for and tend to mention this
|
||||
when sending out an item they obtained this way.
|
||||
|
||||
## Progression
|
||||
Certain items will allow access to more locations and are considered progression items as they "progress" the seed.
|
||||
|
||||
## Trash
|
||||
A term used for "filler" items that have no bearing on the generation and are either marginally useful for the player
|
||||
or useless. These items can be very useful depending on the player but are never very important and as such are usually
|
||||
termed trash.
|
||||
|
||||
## Burger King / BK Mode
|
||||
A term used in multiworlds when a player is unable to continue to progress and is awaiting an item. The term came to be
|
||||
after a player, allegedly, was unable to progress during a multiworld and went to Burger King while waiting to receive
|
||||
items from other players.
|
||||
|
||||
* "Logical BK" is when the player is unable to progress according to the settings of their game but may still be able to do
|
||||
things that would be "out of logic" by the generation.
|
||||
|
||||
* "Hard / full BK" is when the player is completely unable to progress even with tricks they may know and are unable to
|
||||
continue to play, aside from doing something like killing enemies for experience or money.
|
||||
|
||||
## Sphere
|
||||
Archipelago calculates the game playthrough by using a "sphere" system where it has a state for each player and checks
|
||||
to see what the players are able to reach with their current items. Any location that is reachable with the current
|
||||
state of items is a "sphere." For the purposes of Archipelago it starts playthrough calculation by distributing sphere 0
|
||||
items which are items that are either forced in the player's inventory by the game or placed in the `start_inventory` in
|
||||
their settings. Sphere 1 is then all accessible locations the players can reach with all the items they received from
|
||||
sphere 0, or their starting inventory. The playthrough continues in this fashion calculating a number of spheres until
|
||||
all players have completed their goal.
|
||||
|
||||
## Scouts / Scouting
|
||||
In some games there are locations that have visible items even if the item itself is unobtainable at the current time.
|
||||
Some games utilize a scouting feature where when the player "sees" the item it will give a free hint for the item in the
|
||||
client letting the players know what the exact item is, since if the item was for that game it would know but the item
|
||||
being foreign is a lot harder to represent visually.
|
||||
|
||||
@@ -14,7 +14,7 @@ window.addEventListener('load', () => {
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/gameInfo/` +
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
|
||||
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
gameInfo.innerHTML =
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Minecraft
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
Recipes are removed from the crafting book and shuffled into the item pool. It can also optionally change which
|
||||
structures appear in each dimension. Crafting recipes are re-learned when they are received from other players as item
|
||||
checks, and occasionally when completing your own achievements.
|
||||
|
||||
## What is considered a location check in minecraft?
|
||||
|
||||
Location checks in are completed when the player completes various Minecraft achievements. Opening the advancements menu
|
||||
in-game by pressing "L" will display outstanding achievements.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
When the player receives an item in Minecraft, it either unlocks crafting recipes or puts items into the player's
|
||||
inventory directly.
|
||||
|
||||
## What is the victory condition?
|
||||
|
||||
Victory is achieved when the player kills the Ender Dragon, enters the portal in The End, and completes the credits
|
||||
sequence either by skipping it or watching hit play out.
|
||||
@@ -1,43 +0,0 @@
|
||||
# Risk of Rain 2
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
Risk of Rain is already a random game, by virtue of being a roguelite. The Archipelago mod implements pure multiworld
|
||||
functionality in which certain chests (made clear via a location check progress bar) will send an item out to the
|
||||
multiworld. The items that _would have been_ in those chests will be returned to the Risk of Rain player via grants by
|
||||
other players in other worlds.
|
||||
|
||||
## What Risk of Rain items can appear in other players' worlds?
|
||||
|
||||
The Risk of Rain items are:
|
||||
|
||||
* `Common Item` (White items)
|
||||
* `Uncommon Item` (Green items)
|
||||
* `Boss Item` (Yellow items)
|
||||
* `Legendary Item` (Red items)
|
||||
* `Lunar Item` (Blue items)
|
||||
* `Equipment` (Orange items)
|
||||
* `Dio's Best Friend` (Used if you set the YAML setting `total_revives_available` above `0`)
|
||||
|
||||
Each item grants you a random in-game item from the category it belongs to.
|
||||
|
||||
When an item is granted by another world to the Risk of Rain player (one of the items listed above) then a random
|
||||
in-game item of that tier will appear in the Risk of Rain player's inventory. If the item grant is an `Equipment` and
|
||||
the player already has an equipment item equipped then the _item that was equipped_ will be dropped on the ground and _
|
||||
the new equipment_ will take it's place. (If you want the old one back, pick it up.)
|
||||
|
||||
## What does another world's item look like in Risk of Rain?
|
||||
|
||||
When the Risk of Rain player fills up their location check bar then the next spawned item will become an item grant for
|
||||
another player's world. The item in Risk of Rain will disappear in a poof of smoke and the grant will automatically go
|
||||
out to the multiworld.
|
||||
|
||||
## What is the item pickup step?
|
||||
|
||||
The item pickup step is a YAML setting which allows you to set how many items you need to spawn before the _next_ item
|
||||
that is spawned disappears (in a poof of smoke) and goes out to the multiworld.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Rogue Legacy (PC)
|
||||
|
||||
## Where is the settings page?
|
||||
|
||||
The [player settings page for this game](../player-settings) is located contains all the options you need to configure
|
||||
and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
You are not able to buy skill upgrades in the manor upgrade screen, and instead, need to find them in order to level up
|
||||
your character to make fighting the 5 bosses easier.
|
||||
|
||||
## What items and locations get shuffled?
|
||||
|
||||
All the skill upgrades, class upgrades, runes packs, and equipment packs are shuffled in the manor upgrade screen, diary
|
||||
checks, chests and fairy chests, and boss rewards. Skill upgrades are also grouped in packs of 5 to make the finding of
|
||||
stats less of a chore. Runes and Equipment are also grouped together.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
|
||||
Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to limit
|
||||
certain items to your own world.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
When the player receives an item, your character will hold the item above their head and display it to the world. It's
|
||||
good for business!
|
||||
51
WebHostLib/static/assets/glossary.js
Normal file
@@ -0,0 +1,51 @@
|
||||
window.addEventListener('load', () => {
|
||||
const tutorialWrapper = document.getElementById('glossary-wrapper');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, the glossary page is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the glossary.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
|
||||
`glossary_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
// Populate page with HTML generated from markdown
|
||||
showdown.setOption('tables', true);
|
||||
showdown.setOption('strikethrough', true);
|
||||
showdown.setOption('literalMidWordUnderscores', true);
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
`<h2>This page is out of logic!</h2>
|
||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
||||
});
|
||||
});
|
||||
6
WebHostLib/static/assets/lttpMultiTracker.js
Normal file
@@ -0,0 +1,6 @@
|
||||
window.addEventListener('load', () => {
|
||||
$(".table-wrapper").scrollsync({
|
||||
y_sync: true,
|
||||
x_sync: true
|
||||
});
|
||||
});
|
||||
@@ -6,24 +6,24 @@ window.addEventListener('load', () => {
|
||||
// Update game name on page
|
||||
document.getElementById('game-name').innerText = gameName;
|
||||
|
||||
Promise.all([fetchSettingData()]).then((results) => {
|
||||
fetchSettingData().then((results) => {
|
||||
let settingHash = localStorage.getItem(`${gameName}-hash`);
|
||||
if (!settingHash) {
|
||||
// If no hash data has been set before, set it now
|
||||
localStorage.setItem(`${gameName}-hash`, md5(results[0]));
|
||||
settingHash = md5(JSON.stringify(results));
|
||||
localStorage.setItem(`${gameName}-hash`, settingHash);
|
||||
localStorage.removeItem(gameName);
|
||||
settingHash = md5(results[0]);
|
||||
}
|
||||
|
||||
if (settingHash !== md5(results[0])) {
|
||||
if (settingHash !== md5(JSON.stringify(results))) {
|
||||
showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " +
|
||||
"them all to default.");
|
||||
document.getElementById('user-message').addEventListener('click', resetSettings);
|
||||
}
|
||||
|
||||
// Page setup
|
||||
createDefaultSettings(results[0]);
|
||||
buildUI(results[0]);
|
||||
createDefaultSettings(results);
|
||||
buildUI(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Event listeners
|
||||
@@ -36,7 +36,8 @@ window.addEventListener('load', () => {
|
||||
const nameInput = document.getElementById('player-name');
|
||||
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
|
||||
nameInput.value = playerSettings.name;
|
||||
}).catch((error) => {
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
const url = new URL(window.location.href);
|
||||
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
|
||||
})
|
||||
@@ -101,9 +102,15 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
// td Left
|
||||
const tdl = document.createElement('td');
|
||||
const label = document.createElement('label');
|
||||
label.textContent = `${settings[setting].displayName}: `;
|
||||
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);
|
||||
tr.appendChild(tdl);
|
||||
|
||||
@@ -111,6 +118,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
const tdr = document.createElement('td');
|
||||
let element = null;
|
||||
|
||||
const randomButton = document.createElement('button');
|
||||
|
||||
switch(settings[setting].type){
|
||||
case 'select':
|
||||
element = document.createElement('div');
|
||||
@@ -131,8 +140,21 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.addEventListener('change', (event) => updateGameSetting(event));
|
||||
select.addEventListener('change', (event) => updateGameSetting(event.target));
|
||||
element.appendChild(select);
|
||||
|
||||
// Randomize button
|
||||
randomButton.innerText = '🎲';
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', setting);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, [select]));
|
||||
if (currentSettings[gameName][setting] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
select.disabled = true;
|
||||
}
|
||||
|
||||
element.appendChild(randomButton);
|
||||
break;
|
||||
|
||||
case 'range':
|
||||
@@ -147,20 +169,119 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
range.value = currentSettings[gameName][setting];
|
||||
range.addEventListener('change', (event) => {
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event);
|
||||
updateGameSetting(event.target);
|
||||
});
|
||||
element.appendChild(range);
|
||||
|
||||
let rangeVal = document.createElement('span');
|
||||
rangeVal.classList.add('range-value');
|
||||
rangeVal.setAttribute('id', `${setting}-value`);
|
||||
rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
||||
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
||||
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
||||
element.appendChild(rangeVal);
|
||||
|
||||
// Randomize button
|
||||
randomButton.innerText = '🎲';
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', setting);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, [range]));
|
||||
if (currentSettings[gameName][setting] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
range.disabled = true;
|
||||
}
|
||||
|
||||
element.appendChild(randomButton);
|
||||
break;
|
||||
|
||||
case 'special_range':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('special-range-container');
|
||||
|
||||
// Build the select element
|
||||
let specialRangeSelect = document.createElement('select');
|
||||
specialRangeSelect.setAttribute('data-key', setting);
|
||||
Object.keys(settings[setting].value_names).forEach((presetName) => {
|
||||
let presetOption = document.createElement('option');
|
||||
presetOption.innerText = presetName;
|
||||
presetOption.value = settings[setting].value_names[presetName];
|
||||
const words = presetOption.innerText.split("_");
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
|
||||
}
|
||||
presetOption.innerText = words.join(" ");
|
||||
specialRangeSelect.appendChild(presetOption);
|
||||
});
|
||||
let customOption = document.createElement('option');
|
||||
customOption.innerText = 'Custom';
|
||||
customOption.value = 'custom';
|
||||
customOption.selected = true;
|
||||
specialRangeSelect.appendChild(customOption);
|
||||
if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
|
||||
specialRangeSelect.value = Number(currentSettings[gameName][setting]);
|
||||
}
|
||||
|
||||
// Build range element
|
||||
let specialRangeWrapper = document.createElement('div');
|
||||
specialRangeWrapper.classList.add('special-range-wrapper');
|
||||
let specialRange = document.createElement('input');
|
||||
specialRange.setAttribute('type', 'range');
|
||||
specialRange.setAttribute('data-key', setting);
|
||||
specialRange.setAttribute('min', settings[setting].min);
|
||||
specialRange.setAttribute('max', settings[setting].max);
|
||||
specialRange.value = currentSettings[gameName][setting];
|
||||
|
||||
// Build rage value element
|
||||
let specialRangeVal = document.createElement('span');
|
||||
specialRangeVal.classList.add('range-value');
|
||||
specialRangeVal.setAttribute('id', `${setting}-value`);
|
||||
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
|
||||
currentSettings[gameName][setting] : settings[setting].defaultValue;
|
||||
|
||||
// Configure select event listener
|
||||
specialRangeSelect.addEventListener('change', (event) => {
|
||||
if (event.target.value === 'custom') { return; }
|
||||
|
||||
// Update range slider
|
||||
specialRange.value = event.target.value;
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event.target);
|
||||
});
|
||||
|
||||
// Configure range event handler
|
||||
specialRange.addEventListener('change', (event) => {
|
||||
// Update select element
|
||||
specialRangeSelect.value =
|
||||
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
||||
parseInt(event.target.value) : 'custom';
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event.target);
|
||||
});
|
||||
|
||||
element.appendChild(specialRangeSelect);
|
||||
specialRangeWrapper.appendChild(specialRange);
|
||||
specialRangeWrapper.appendChild(specialRangeVal);
|
||||
element.appendChild(specialRangeWrapper);
|
||||
|
||||
// Randomize button
|
||||
randomButton.innerText = '🎲';
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', setting);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(
|
||||
event, [specialRange, specialRangeSelect])
|
||||
);
|
||||
if (currentSettings[gameName][setting] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
specialRange.disabled = true;
|
||||
specialRangeSelect.disabled = true;
|
||||
}
|
||||
|
||||
specialRangeWrapper.appendChild(randomButton);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown setting type: ${settings[setting].type}`);
|
||||
console.error(setting);
|
||||
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,6 +294,25 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
return table;
|
||||
};
|
||||
|
||||
const toggleRandomize = (event, inputElements) => {
|
||||
const active = event.target.classList.contains('active');
|
||||
const randomButton = event.target;
|
||||
|
||||
if (active) {
|
||||
randomButton.classList.remove('active');
|
||||
for (const element of inputElements) {
|
||||
element.disabled = undefined;
|
||||
updateGameSetting(element);
|
||||
}
|
||||
} else {
|
||||
randomButton.classList.add('active');
|
||||
for (const element of inputElements) {
|
||||
element.disabled = true;
|
||||
updateGameSetting(randomButton);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateBaseSetting = (event) => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
@@ -180,10 +320,17 @@ const updateBaseSetting = (event) => {
|
||||
localStorage.setItem(gameName, JSON.stringify(options));
|
||||
};
|
||||
|
||||
const updateGameSetting = (event) => {
|
||||
const updateGameSetting = (settingElement) => {
|
||||
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));
|
||||
};
|
||||
|
||||
|
||||
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 tablesContainer = document.getElementById('tables-container');
|
||||
if (!tablesContainer)
|
||||
return;
|
||||
const upperDistance = tablesContainer.getBoundingClientRect().top;
|
||||
|
||||
const containerHeight = window.innerHeight - upperDistance;
|
||||
@@ -17,6 +19,14 @@ window.addEventListener('load', () => {
|
||||
paging: false,
|
||||
info: false,
|
||||
dom: "t",
|
||||
stateSave: true,
|
||||
stateSaveCallback: function(settings, data) {
|
||||
delete data.search;
|
||||
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
|
||||
},
|
||||
stateLoadCallback: function(settings) {
|
||||
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
|
||||
},
|
||||
columnDefs: [
|
||||
{
|
||||
targets: 'hours',
|
||||
@@ -63,16 +73,44 @@ window.addEventListener('load', () => {
|
||||
// the tbody and render two separate tables.
|
||||
});
|
||||
|
||||
document.getElementById('search').addEventListener('keyup', (event) => {
|
||||
tables.search(event.target.value);
|
||||
console.info(tables.search());
|
||||
const searchBox = document.getElementById("search");
|
||||
searchBox.value = tables.search();
|
||||
searchBox.focus();
|
||||
searchBox.select();
|
||||
const doSearch = () => {
|
||||
tables.search(searchBox.value);
|
||||
tables.draw();
|
||||
};
|
||||
searchBox.addEventListener("keyup", doSearch);
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (!event.ctrlKey && !event.altKey && event.key.length === 1 && document.activeElement !== searchBox) {
|
||||
searchBox.focus();
|
||||
searchBox.select();
|
||||
}
|
||||
if (!event.ctrlKey && !event.altKey && !event.shiftKey && event.key === "Escape") {
|
||||
if (searchBox.value !== "") {
|
||||
searchBox.value = "";
|
||||
doSearch();
|
||||
}
|
||||
searchBox.blur();
|
||||
if (!document.getElementById("tables-container"))
|
||||
window.scroll(0, 0);
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||
const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
|
||||
|
||||
function getSleepTimeSeconds(){
|
||||
// -40 % 60 is -40, which is absolutely wrong and should burn
|
||||
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
|
||||
return sleepSeconds || 60;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const target = $("<div></div>");
|
||||
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||
target.load("/tracker/" + tracker, function (response, status) {
|
||||
console.log("Updating Tracker...");
|
||||
target.load(location.href, function (response, status) {
|
||||
if (status === "success") {
|
||||
target.find(".table").each(function (i, new_table) {
|
||||
const new_trs = $(new_table).find("tbody>tr");
|
||||
@@ -90,19 +128,14 @@ window.addEventListener('load', () => {
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||
}
|
||||
|
||||
setInterval(update, 30000);
|
||||
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
adjustTableHeight();
|
||||
tables.draw();
|
||||
});
|
||||
|
||||
$(".table-wrapper").scrollsync({
|
||||
y_sync: true,
|
||||
x_sync: true
|
||||
});
|
||||
|
||||
adjustTableHeight();
|
||||
});
|
||||
@@ -14,7 +14,7 @@ window.addEventListener('load', () => {
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/tutorial/` +
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
|
||||
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
|
||||
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
||||
ajax.send();
|
||||
@@ -27,25 +27,28 @@ window.addEventListener('load', () => {
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
const title = document.querySelector('h1')
|
||||
if (title) {
|
||||
document.title = title.textContent;
|
||||
}
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Subnautica Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- Subnautica from: [Subnautica Steam Store Page](https://store.steampowered.com/app/264710/Subnautica/)
|
||||
- QModManager4 from: [QModManager4 Nexus Mods Page](https://www.nexusmods.com/subnautica/mods/201)
|
||||
- Archipelago Mod for Subnautica
|
||||
from: [Subnautica Archipelago Mod Releases Page](https://github.com/Berserker66/ArchipelagoSubnauticaModSrc/releases)
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
1. Install QModManager4 as per its instructions.
|
||||
|
||||
2. The folder you installed QModManager4 into will now have a /QMods directory. It might appear after a start of
|
||||
Subnautica. You can also create this folder yourself.
|
||||
|
||||
3. Unpack the Archipelago Mod into this folder, so that Subnautica/QMods/Archipelago/ is a valid path.
|
||||
|
||||
4. Start Subnautica. You should see a Connect Menu in the topleft of your main Menu.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you don't see the connect window check that you see a qmodmanager_log-Subnautica.txt in Subnautica, if not
|
||||
QModManager4 is not correctly installed, otherwise open it and look
|
||||
for `[Info : BepInEx] Loading [Archipelago 1.0.0.0]`, version number doesn't matter. If it doesn't show this, then
|
||||
QModManager4 didn't find the Archipelago mod, so check your paths.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. In Host, enter the address of the server, such as archipelago.gg:38281, your server host should be able to tell you
|
||||
this.
|
||||
|
||||
2. In Password enter the server password if one exists, otherwise leave blank.
|
||||
|
||||
3. In PlayerName enter your "name" field from the yaml, or website config.
|
||||
|
||||
4. Hit Connect. If it says successfully authenticated you can now create a new savegame or resume the correct savegame.
|
||||
@@ -1,129 +0,0 @@
|
||||
# Archipelago Triggers Guide
|
||||
|
||||
This guide details the use of the Archipelago YAML trigger system. This guide is intended for a more advanced user with
|
||||
more in-depth knowledge of Archipelago YAML options as well as experience editing YAML files. This guide should take
|
||||
about 5 minutes to read.
|
||||
|
||||
## What are triggers?
|
||||
|
||||
Triggers allow you to customize your game settings by allowing you to define one or many options which only occur under
|
||||
specific conditions. These are essentially "if, then" statements for options in your game. A good example of what you
|
||||
can do with triggers is the custom mercenary mode YAML that was created using entirely triggers and plando.
|
||||
|
||||
Mercenary mode
|
||||
YAML: [Mercenary Mode YAML on GitHub](https://github.com/alwaysintreble/Archipelago-yaml-dump/blob/main/Snippets/Mercenary%20Mode%20Snippet.yaml)
|
||||
|
||||
For more information on plando you can reference the general plando guide or the Link to the Past plando guide.
|
||||
|
||||
General plando guide: [Archipelago Plando Guide](/tutorial/archipelago/plando/en)
|
||||
|
||||
Link to the Past plando guide: [LttP Plando Guide](/tutorial/zelda3/plando/en)
|
||||
|
||||
## Trigger use
|
||||
|
||||
Triggers may be defined in either the root or in the relevant game sections. Generally, The best place to do this is the
|
||||
bottom of the yaml for clear organization.
|
||||
|
||||
- Triggers comprise the trigger section and then each trigger must have an `option_category`, `option_name`, and
|
||||
`option_result` from which it will react to and then an `options` section for the definition of what will happen.
|
||||
- `option_category` is the defining section from which the option is defined in.
|
||||
- Example: `A Link to the Past`
|
||||
- This is the root category the option is located in. If the option you're triggering off of is in root then you
|
||||
would use `null`, otherwise this is the game for which you want this option trigger to activate.
|
||||
- `option_name` is the option setting from which the triggered choice is going to react to.
|
||||
- Example: `shop_item_slots`
|
||||
- This can be any option from any category defined in the yaml file in either root or a game section.
|
||||
- `option_result` is the result of this option setting from which you would like to react.
|
||||
- Example: `15`
|
||||
- Each trigger must be used for exactly one option result. If you would like the same thing to occur with multiple
|
||||
results you would need multiple triggers for this.
|
||||
- `options` is where you define what will happen when this is detected. This can be something as simple as ensuring
|
||||
another option also gets selected or placing an item in a certain location. It is possible to have multiple things
|
||||
happen in this section.
|
||||
- Example:
|
||||
```yaml
|
||||
A Link to the Past:
|
||||
start_inventory:
|
||||
Rupees (300): 2
|
||||
```
|
||||
|
||||
This format must be:
|
||||
|
||||
```yaml
|
||||
root option:
|
||||
option to change:
|
||||
desired result
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
The above examples all together will end up looking like this:
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- option_category: A Link to the Past
|
||||
option_name: shop_item_slots
|
||||
option_result: 15
|
||||
options:
|
||||
A Link to the Past:
|
||||
start_inventory:
|
||||
Rupees(300): 2
|
||||
```
|
||||
|
||||
For this example if the generator happens to roll 15 shuffled in shop item slots for your game you'll be granted 600
|
||||
rupees at the beginning. These can also be used to change other options.
|
||||
|
||||
For example:
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- option_category: Timespinner
|
||||
option_name: SpecificKeycards
|
||||
option_result: true
|
||||
options:
|
||||
Timespinner:
|
||||
Inverted: true
|
||||
```
|
||||
|
||||
In this example if your world happens to roll SpecificKeycards then your game will also start in inverted.
|
||||
|
||||
It is also possible to use imaginary names in options to trigger specific settings. You can use these made up names in
|
||||
either your main options or to trigger from another trigger. Currently, this is the only way to trigger on "setting 1
|
||||
AND setting 2".
|
||||
|
||||
For example:
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- option_category: Secret of Evermore
|
||||
option_name: doggomizer
|
||||
option_result: pupdunk
|
||||
options:
|
||||
Secret of Evermore:
|
||||
difficulty:
|
||||
normal: 50
|
||||
pupdunk_hard: 25
|
||||
pupdunk_mystery: 25
|
||||
exp_modifier:
|
||||
150: 50
|
||||
200: 50
|
||||
- option_category: Secret of Evermore
|
||||
option_name: difficulty
|
||||
option_result: pupdunk_hard
|
||||
options:
|
||||
Secret of Evermore:
|
||||
fix_wings_glitch: false
|
||||
difficulty: hard
|
||||
- option_category: Secret of Evermore
|
||||
option_name: difficulty
|
||||
option_result: pupdunk_mystery
|
||||
options:
|
||||
Secret of Evermore:
|
||||
fix_wings_glitch: false
|
||||
difficulty: mystery
|
||||
```
|
||||
|
||||
In this example (thanks to @Black-Sliver) if the `pupdunk` option is rolled then the difficulty values will be rolled
|
||||
again using the new options `normal`, `pupdunk_hard`, and `pupdunk_mystery`, and the exp modifier will be rerolled using
|
||||
new weights for 150 and 200. This allows for two more triggers that will only be used for the new `pupdunk_hard`
|
||||
and `pupdunk_mystery` options so that they will only be triggered on "pupdunk AND hard/mystery".
|
||||
@@ -1,107 +0,0 @@
|
||||
# Risk of Rain 2 Setup Guide
|
||||
|
||||
## Install using r2modman
|
||||
|
||||
### Install r2modman
|
||||
|
||||
Head on over to the r2modman page on Thunderstore and follow the installation instructions.
|
||||
|
||||
[r2modman Page](https://thunderstore.io/package/ebkr/r2modman/)
|
||||
|
||||
### Install Archipelago Mod using r2modman
|
||||
|
||||
You can install the Archipelago mod using r2modman in one of two ways.
|
||||
|
||||
[Archipelago Mod Download Page](https://thunderstore.io/package/ArchipelagoMW/Archipelago/)
|
||||
|
||||
One, you can use the Thunderstore website and click on the "Install with Mod Manager" link.
|
||||
|
||||
You can also search for the "Archipelago" mod in the r2modman interface. The mod manager should automatically install
|
||||
all necessary dependencies as well.
|
||||
|
||||
### Running the Modded Game
|
||||
|
||||
Click on the "Start modded" button in the top left in r2modman to start the game with the Archipelago mod installed.
|
||||
|
||||
## Joining an Archipelago Session
|
||||
|
||||
There will be a menu button on the right side of the screen in the character select menu. Click it in order to bring up
|
||||
the in lobby mod config. From here you can expand the Archipelago sections and fill in the relevant info. Keep password
|
||||
blank if there is no password on the server.
|
||||
|
||||
Simply check `Enable Archipelago?` and when you start the run it will automatically connect.
|
||||
|
||||
## Gameplay
|
||||
|
||||
The Risk of Rain 2 players send checks by causing items to spawn in-game. That means opening chests or killing bosses,
|
||||
generally. An item check is only sent out after a certain number of items are picked up. This count is configurable in
|
||||
the player's YAML.
|
||||
|
||||
## YAML Settings
|
||||
|
||||
An example YAML would look like this:
|
||||
|
||||
```yaml
|
||||
description: Ijwu-ror2
|
||||
name: Ijwu
|
||||
|
||||
game:
|
||||
Risk of Rain 2: 1
|
||||
|
||||
Risk of Rain 2:
|
||||
total_locations: 15
|
||||
total_revivals: 4
|
||||
start_with_revive: true
|
||||
item_pickup_step: 1
|
||||
enable_lunar: true
|
||||
item_weights:
|
||||
default: 50
|
||||
new: 0
|
||||
uncommon: 0
|
||||
legendary: 0
|
||||
lunartic: 0
|
||||
chaos: 0
|
||||
no_scraps: 0
|
||||
even: 0
|
||||
scraps_only: 0
|
||||
item_pool_presets: true
|
||||
# custom item weights
|
||||
green_scrap: 16
|
||||
red_scrap: 4
|
||||
yellow_scrap: 1
|
||||
white_scrap: 32
|
||||
common_item: 64
|
||||
uncommon_item: 32
|
||||
legendary_item: 8
|
||||
boss_item: 4
|
||||
lunar_item: 16
|
||||
equipment: 32
|
||||
```
|
||||
|
||||
| Name | Description | Allowed values |
|
||||
| ---- | ----------- | -------------- |
|
||||
| total_locations | The total number of location checks that will be attributed to the Risk of Rain player. This option is ALSO the total number of items in the item pool for the Risk of Rain player. | 10 - 100 |
|
||||
| total_revivals | The total number of items in the Risk of Rain player's item pool (items other players pick up for them) replaced with `Dio's Best Friend`. | 0 - 5 |
|
||||
| start_with_revive | Starts the player off with a `Dio's Best Friend`. Functionally equivalent to putting a `Dio's Best Friend` in your `starting_inventory`. | true/false |
|
||||
| item_pickup_step | The number of item pickups which you are allowed to claim before they become an Archipelago location check. | 0 - 5 |
|
||||
| enable_lunar | Allows for lunar items to be shuffled into the item pool on behalf of the Risk of Rain player. | true/false |
|
||||
| item_weights | Each option here is a preset item weight that can be used to customize your generate item pool with certain settings. | default, new, uncommon, legendary, lunartic, chaos, no_scraps, even, scraps_only |
|
||||
| item_pool_presets | A simple toggle to determine whether the item_weight presets are used or the custom item pool as defined below | true/false |
|
||||
| custom item weights | Each defined item here is a single item in the pool that will have a weight against the other items when the item pool gets generated. These values can be modified to adjust how frequently certain items appear | 0-100|
|
||||
|
||||
Using the example YAML above: the Risk of Rain 2 player will have 15 total items which they can pick up for other
|
||||
players. (total_locations = 15)
|
||||
|
||||
They will have 15 items waiting for them in the item pool which will be distributed out to the multiworld. (
|
||||
total_locations = 15)
|
||||
|
||||
They will complete a location check every second item. (item_pickup_step = 1)
|
||||
|
||||
They will have 4 of the items which other players can grant them replaced with `Dio's Best Friend`. (total_revivals = 4)
|
||||
|
||||
The player will also start with a `Dio's Best Friend`. (start_with_revive = true)
|
||||
|
||||
The player will have lunar items shuffled into the item pool on their behalf. (enable_lunar = true)
|
||||
|
||||
The player will have the default preset generated item pool with the custom item weights being ignored. (item_weights:
|
||||
default and item_pool_presets: true)
|
||||
@@ -1,82 +0,0 @@
|
||||
# Super Mario 64 EX MultiWorld Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- Super Mario 64 US Rom (Japanese may work also. Europe and Shindou not supported)
|
||||
- Either of [sm64pclauncher](https://github.com/N00byKing/sm64pclauncher/releases) or
|
||||
- Cloning and building [sm64ex](https://github.com/N00byKing/sm64ex) manually.
|
||||
|
||||
NOTE: The above linked sm64pclauncher is a special version designed to work with the Archipelago build of sm64ex.
|
||||
You can use other sm64-port based builds with it, but you can't use a different launcher with the Archipelago build of sm64ex.
|
||||
|
||||
## Installation and Game Start Procedures
|
||||
|
||||
# Installation via sm64pclauncher (For Windows)
|
||||
|
||||
First, install [MSYS](https://www.msys2.org/) as described on the page. DO NOT INSTALL INTO A FOLDER PATH WITH SPACES.
|
||||
Do all steps up to including step 6.
|
||||
Best use default install directory.
|
||||
Then follow the steps below
|
||||
|
||||
1. Go to the page linked for sm64pclauncher, and press on the topmost entry
|
||||
3. Scroll down, and download the zip file
|
||||
4. Unpack the zip file in an empty folder
|
||||
5. Run the Launcher and press build.
|
||||
6. Set the location where you installed MSYS when prompted. Check the "Install Dependencies" Checkbox
|
||||
7. Set the Repo link to `https://github.com/N00byKing/sm64ex` and the Branch to `archipelago` (Top two boxes). You can choose the folder (Secound Box) at will, as long as it does not exist yet
|
||||
8. Point the Launcher to your Super Mario 64 US/JP Rom, and set the Region correspondingly
|
||||
9. Set Build Options. Recommended: `-jn` where `n` is the Number of CPU Cores, to build faster.
|
||||
10. SM64EX will now be compiled. The Launcher will appear to have crashed, but this is not likely the case. Best wait a bit, but there may be a problem if it takes longer than 10 Minutes
|
||||
|
||||
After it's done, the Build list should have another entry titled with what you named the folder in step 7.
|
||||
|
||||
NOTE: For some reason first start of the game always crashes the launcher. Just restart it.
|
||||
If it still crashes, recheck if you typed the launch options correctly (Described in "Joining a MultiWorld Game")
|
||||
|
||||
# Manual Compilation (Linux/Windows)
|
||||
|
||||
Dependencies for Linux: `sdl2 glew cmake python make`.
|
||||
Dependencies for Windows: `mingw-w64-x86_64-gcc mingw-w64-x86_64-glew mingw-w64-x86_64-SDL2 git make python3 cmake`
|
||||
SM64EX will link `jsoncpp` dynamic if installed. If not, it will compile and link statically.
|
||||
|
||||
1. Clone `https://github.com/N00byKing/sm64ex` recursively
|
||||
2. Enter `sm64ex` and copy your Rom to `baserom.REGION.z64` where `REGION` is either `us` or `jp` respectively.
|
||||
3. Compile with `make`. For faster compilation set the parameter `-jn` where `n` is the Number of CPU Cores.
|
||||
|
||||
The Compiled binary will be in `build/REGION_pc/`.
|
||||
|
||||
# Joining a MultiWorld Game
|
||||
|
||||
To join, set the following launch options: `--sm64ap_name YourName --sm64ap_ip ServerIP:Port`.
|
||||
Optionally, add `--sm64ap_passwd "YourPassword"` if the room you are using requires a password. All parameters without quotation marks.
|
||||
The Name in this case is the one specified in your generated .yaml file.
|
||||
In case you are using the Archipelago Website, the IP should be `archipelago.gg` and Port `38281`.
|
||||
|
||||
If everything worked out, you will see a textbox informing you the connection has been established after the story intro.
|
||||
|
||||
## Installation Troubleshooting
|
||||
|
||||
Start the game from the command line to view helpful messages regarding SM64EX.
|
||||
|
||||
### Game doesn't start after compiling
|
||||
|
||||
Most likely you forgot to set the launch options. `--sm64ap_name YourName` and `--sm64ap_ip ServerIP:Port` are required for startup.
|
||||
If your Name or Password have spaces in them, surround them in quotes.
|
||||
|
||||
## Game Troubleshooting
|
||||
|
||||
### Known Issues
|
||||
|
||||
When using a US Rom, the In-Game messages are missing some letters: `J Q V X Z` and `?`.
|
||||
The Japanese Version should have no problem displaying these.
|
||||
|
||||
### What happens if I lose connection?
|
||||
|
||||
SM64EX tries to reconnect a few times, so be patient.
|
||||
Should the problem still be there after about a minute or two, just save and restart the game.
|
||||
|
||||
### How do I update the Game to a new Build?
|
||||
|
||||
When manually compiling just pull in changes and run `make` again. Sometimes it helps to run `make clean` before.
|
||||
|
||||
When using the Launcher follow the normal build steps, but when choosing a folder name use the same as before. Then continue as normal.
|
||||
@@ -1,483 +0,0 @@
|
||||
[
|
||||
{
|
||||
"gameTitle": "Archipelago",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago software to generate and host multiworld games on your computer and using the website.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/setup_en.md",
|
||||
"link": "archipelago/setup/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Archipelago Website User Guide",
|
||||
"description": "A guide to using the Archipelago website to generate multiworlds or host pre-generated multiworlds.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/using_website.md",
|
||||
"link": "archipelago/using_website/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Archipelago Server and Client Commands",
|
||||
"description": "A guide detailing the commands available to the user when participating in an Archipelago session.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/commands_en.md",
|
||||
"link": "archipelago/commands/en",
|
||||
"authors": [
|
||||
"jat2980",
|
||||
"Ijwu"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Advanced YAML Guide",
|
||||
"description": "A guide to reading yaml files and editing them to fully customize your game.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/advanced_settings_en.md",
|
||||
"link": "archipelago/advanced_settings/en",
|
||||
"authors": [
|
||||
"alwaysintreble",
|
||||
"Alchav"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Archipelago Triggers Guide",
|
||||
"description": "A guide to setting up and using triggers in your game settings.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/triggers_en.md",
|
||||
"link": "archipelago/triggers/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Archipelago Plando Guide",
|
||||
"description": "A guide to understanding and using plando for your game.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/plando_en.md",
|
||||
"link": "archipelago/plando/en",
|
||||
"authors": [
|
||||
"alwaysintreble",
|
||||
"Alchav"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "The Legend of Zelda: A Link to the Past",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago ALttP software on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda3/multiworld_en.md",
|
||||
"link": "zelda3/multiworld/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Deutsch",
|
||||
"filename": "zelda3/multiworld_de.md",
|
||||
"link": "zelda3/multiworld/de",
|
||||
"authors": [
|
||||
"Fischfilet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Español",
|
||||
"filename": "zelda3/multiworld_es.md",
|
||||
"link": "zelda3/multiworld/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Français",
|
||||
"filename": "zelda3/multiworld_fr.md",
|
||||
"link": "zelda3/multiworld/fr",
|
||||
"authors": [
|
||||
"Coxla"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MSU-1 Setup Tutorial",
|
||||
"description": "A guide to setting up MSU-1, which allows for custom in-game music.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda3/msu1_en.md",
|
||||
"link": "zelda3/msu1/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Español",
|
||||
"filename": "zelda3/msu1_es.md",
|
||||
"link": "zelda3/msu1/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Français",
|
||||
"filename": "msu1_fr.md",
|
||||
"link": "zelda3/msu1/fr",
|
||||
"authors": [
|
||||
"Coxla"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Plando Tutorial",
|
||||
"description": "A guide to creating Multiworld Plandos",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda3/plando_en.md",
|
||||
"link": "zelda3/plando/en",
|
||||
"authors": [
|
||||
"Berserker"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "The Legend of Zelda: Ocarina of Time",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago Ocarina of Time software on your computer.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda5/setup_en.md",
|
||||
"link": "zelda5/setup/en",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Spanish",
|
||||
"filename": "zelda5/setup_es.md",
|
||||
"link": "zelda5/setup/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Factorio",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago Factorio software on your computer.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "factorio/setup_en.md",
|
||||
"link": "factorio/setup/en",
|
||||
"authors": [
|
||||
"Berserker",
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Minecraft",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago Minecraft software on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "minecraft/minecraft_en.md",
|
||||
"link": "minecraft/minecraft/en",
|
||||
"authors": [
|
||||
"Kono Tyran"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Spanish",
|
||||
"filename": "minecraft/minecraft_es.md",
|
||||
"link": "minecraft/minecraft/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Swedish",
|
||||
"filename": "minecraft/minecraft_sv.md",
|
||||
"link": "minecraft/minecraft/sv",
|
||||
"authors": [
|
||||
"Albinum"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Risk of Rain 2",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Risk of Rain 2 integration for Archipelago multiworld games.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "ror2/setup_en.md",
|
||||
"link": "ror2/setup/en",
|
||||
"authors": [
|
||||
"Ijwu"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Raft",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up Raft integration for Archipelago multiworld games.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "raft/setup_en.md",
|
||||
"link": "raft/setup/en",
|
||||
"authors": [
|
||||
"SunnyBat",
|
||||
"Awareqwx"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Timespinner",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Timespinner randomizer connected to an Archipelago Multiworld",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "timespinner/setup_en.md",
|
||||
"link": "timespinner/setup/en",
|
||||
"authors": [
|
||||
"Jarno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "German",
|
||||
"filename": "timespinner/setup_de.md",
|
||||
"link": "timespinner/setup/de",
|
||||
"authors": [
|
||||
"Grrmo",
|
||||
"Fynxes",
|
||||
"Blaze0168"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Subnautica",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Subnautica randomizer connected to an Archipelago Multiworld",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Subnautica/setup_en.md",
|
||||
"link": "Subnautica/setup/en",
|
||||
"authors": [
|
||||
"Berserker"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Super Metroid",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Super Metroid Client on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "super-metroid/multiworld_en.md",
|
||||
"link": "super-metroid/multiworld/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Secret of Evermore",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to playing Secret of Evermore randomizer. This guide covers single-player, multiworld and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "secret-of-evermore/multiworld_en.md",
|
||||
"link": "secret-of-evermore/multiworld/en",
|
||||
"authors": [
|
||||
"Black Sliver"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Final Fantasy",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to playing Final Fantasy multiworld. This guide only covers playing multiworld.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "ff1/multiworld_en.md",
|
||||
"link": "ff1/multiworld/en",
|
||||
"authors": [
|
||||
"jat2980"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Rogue Legacy",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Rogue Legacy Randomizer software on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "rogue-legacy/rogue-legacy_en.md",
|
||||
"link": "rogue-legacy/rogue-legacy/en",
|
||||
"authors": [
|
||||
"Phar"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Slay the Spire",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up Slay the Spire for Archipelago. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "slay-the-spire/slay-the-spire_en.md",
|
||||
"link": "slay-the-spire/slay-the-spire/en",
|
||||
"authors": [
|
||||
"Phar"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Super Mario 64 EX",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up SM64EX for MultiWorld.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "sm64ex/setup_en.md",
|
||||
"link": "sm64ex/setup/en",
|
||||
"authors": [
|
||||
"N00byKing"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "VVVVVV",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up VVVVVV for MultiWorld.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "v6/setup_en.md",
|
||||
"link": "v6/setup/en",
|
||||
"authors": [
|
||||
"N00byKing"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,37 +0,0 @@
|
||||
# VVVVVV MultiWorld Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- VVVVVV (Bought from the [Steam Store](https://store.steampowered.com/app/70300/VVVVVV/) or [GOG Store](https://www.gog.com/game/vvvvvv) Page, NOT Make and Play Edition!)
|
||||
- [V6AP](https://github.com/N00byKing/VVVVVV/releases)
|
||||
|
||||
## Installation and Game Start Procedures
|
||||
|
||||
1. Install VVVVVV through either Steam or GOG
|
||||
2. Go to the page linked for V6AP, and download the latest release
|
||||
3. Unpack the zip file where you have VVVVVV installed.
|
||||
|
||||
# Joining a MultiWorld Game
|
||||
|
||||
To join, set the following launch options: `-v6ap_name YourName -v6ap_ip ServerIP:Port`.
|
||||
Optionally, add `-v6ap_passwd "YourPassword"` if the room you are using requires a password. All parameters without quotation marks.
|
||||
The Name in this case is the one specified in your generated .yaml file.
|
||||
In case you are using the Archipelago Website, the IP should be `archipelago.gg`.
|
||||
|
||||
If everything worked out, you will see a textbox informing you the connection has been established after the story intro.
|
||||
|
||||
## Installation Troubleshooting
|
||||
|
||||
Start the game from the command line to view helpful messages regarding V6AP. These will look something like "V6AP: Message"
|
||||
|
||||
### Game no longer starts after copying the .exe
|
||||
|
||||
Most likely you forgot to set the launch options. `-v6ap_name YourName` and `-v6ap_ip ServerIP:Port` are required for startup.
|
||||
If your Name or Password have spaces in them, surround them in quotes.
|
||||
|
||||
## Game Troubleshooting
|
||||
|
||||
### What happens if I lose connection?
|
||||
|
||||
V6AP tries to reconnect a few times, so be patient.
|
||||
Should the problem still be there after about a minute or two, just save and restart the game.
|
||||
@@ -23,6 +23,7 @@ window.addEventListener('load', () => {
|
||||
games.forEach((game) => {
|
||||
const gameTitle = document.createElement('h2');
|
||||
gameTitle.innerText = game.gameTitle;
|
||||
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
|
||||
tutorialDiv.appendChild(gameTitle);
|
||||
|
||||
game.tutorials.forEach((tutorial) => {
|
||||
@@ -65,7 +66,16 @@ window.addEventListener('load', () => {
|
||||
showError();
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// Check if we are on an anchor when coming in, and scroll to it.
|
||||
const hash = window.location.hash;
|
||||
if (hash) {
|
||||
const offset = 128; // To account for navbar banner at top of page.
|
||||
window.scrollTo(0, 0);
|
||||
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
|
||||
window.scrollTo(rect.left, rect.top - offset);
|
||||
}
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/tutorial/tutorials.json`, true);
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ window.addEventListener('load', () => {
|
||||
"order": [[ 3, "desc" ]],
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
$("#seeds-table").DataTable({
|
||||
"paging": false,
|
||||
@@ -13,5 +14,6 @@ window.addEventListener('load', () => {
|
||||
"order": [[ 2, "desc" ]],
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,9 @@ window.addEventListener('load', () => {
|
||||
let settingHash = localStorage.getItem('weighted-settings-hash');
|
||||
if (!settingHash) {
|
||||
// If no hash data has been set before, set it now
|
||||
localStorage.setItem('weighted-settings-hash', md5(JSON.stringify(results)));
|
||||
localStorage.removeItem('weighted-settings');
|
||||
settingHash = md5(JSON.stringify(results));
|
||||
localStorage.setItem('weighted-settings-hash', settingHash);
|
||||
localStorage.removeItem('weighted-settings');
|
||||
}
|
||||
|
||||
if (settingHash !== md5(JSON.stringify(results))) {
|
||||
@@ -77,13 +77,17 @@ const createDefaultSettings = (settingData) => {
|
||||
});
|
||||
break;
|
||||
case 'range':
|
||||
for (let i = setting.min; i <= setting.max; ++i){
|
||||
newSettings[game][gameSetting][i] =
|
||||
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
|
||||
}
|
||||
case 'special_range':
|
||||
newSettings[game][gameSetting][setting.min] = 0;
|
||||
newSettings[game][gameSetting][setting.max] = 0;
|
||||
newSettings[game][gameSetting]['random'] = 0;
|
||||
newSettings[game][gameSetting]['random-low'] = 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;
|
||||
|
||||
case 'items-list':
|
||||
@@ -285,6 +289,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
break;
|
||||
|
||||
case 'range':
|
||||
case 'special_range':
|
||||
const rangeTable = document.createElement('table');
|
||||
const rangeTbody = document.createElement('tbody');
|
||||
|
||||
@@ -325,6 +330,14 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' +
|
||||
`below, then press the "Add" button to add a weight for it.<br />Minimum value: ${setting.min}<br />` +
|
||||
`Maximum value: ${setting.max}`;
|
||||
|
||||
if (setting.hasOwnProperty('value_names')) {
|
||||
hintText.innerHTML += '<br /><br />Certain values have special meaning:';
|
||||
Object.keys(setting.value_names).forEach((specialName) => {
|
||||
hintText.innerHTML += `<br />${specialName}: ${setting.value_names[specialName]}`;
|
||||
});
|
||||
}
|
||||
|
||||
settingWrapper.appendChild(hintText);
|
||||
|
||||
const addOptionDiv = document.createElement('div');
|
||||
@@ -391,11 +404,17 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
tr.appendChild(tdDelete);
|
||||
|
||||
rangeTbody.appendChild(tr);
|
||||
|
||||
// Save new option to settings
|
||||
range.dispatchEvent(new Event('change'));
|
||||
});
|
||||
|
||||
Object.keys(currentSettings[game][settingName]).forEach((option) => {
|
||||
if (currentSettings[game][settingName][option] > 0) {
|
||||
const tr = document.createElement('tr');
|
||||
// These options are statically generated below, and should always appear even if they are deleted
|
||||
// from localStorage
|
||||
if (['random-low', 'random', 'random-high'].includes(option)) { return; }
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
const tdLeft = document.createElement('td');
|
||||
tdLeft.classList.add('td-left');
|
||||
tdLeft.innerText = option;
|
||||
@@ -429,14 +448,15 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
deleteButton.innerText = '❌';
|
||||
deleteButton.addEventListener('click', () => {
|
||||
range.value = 0;
|
||||
range.dispatchEvent(new Event('change'));
|
||||
const changeEvent = new Event('change');
|
||||
changeEvent.action = 'rangeDelete';
|
||||
range.dispatchEvent(changeEvent);
|
||||
rangeTbody.removeChild(tr);
|
||||
});
|
||||
tdDelete.appendChild(deleteButton);
|
||||
tr.appendChild(tdDelete);
|
||||
|
||||
rangeTbody.appendChild(tr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -487,7 +507,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown setting type for ${game} setting ${setting}: ${settings[setting].type}`);
|
||||
console.error(`Unknown setting type for ${game} setting ${settingName}: ${setting.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -894,8 +914,12 @@ const updateGameSetting = (evt) => {
|
||||
const setting = evt.target.getAttribute('data-setting');
|
||||
const option = evt.target.getAttribute('data-option');
|
||||
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
|
||||
options[game][setting][option] = isNaN(evt.target.value) ?
|
||||
evt.target.value : parseInt(evt.target.value, 10);
|
||||
console.log(event);
|
||||
if (evt.action && evt.action === 'rangeDelete') {
|
||||
delete options[game][setting][option];
|
||||
} else {
|
||||
options[game][setting][option] = parseInt(evt.target.value, 10);
|
||||
}
|
||||
localStorage.setItem('weighted-settings', JSON.stringify(options));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
Copyright 2022 Berserker66 (Fabian Dill)
|
||||
Copyright 2022 LegendaryLinux (Chris Wilson)
|
||||
|
||||
All rights reserved.
|
||||
|
||||
BIN
WebHostLib/static/static/backgrounds/dirt.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
BIN
WebHostLib/static/static/backgrounds/grass-flowers.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
WebHostLib/static/static/backgrounds/grass.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 25 KiB |
BIN
WebHostLib/static/static/backgrounds/header/stone-header.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
WebHostLib/static/static/backgrounds/ice.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
WebHostLib/static/static/backgrounds/jungle.png
Normal file
|
After Width: | Height: | Size: 36 KiB |