mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-18 21:38:13 -07:00
Compare commits
1134 Commits
NewSoupVi-
...
0.6.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45994e344e | ||
|
|
51d5e1afae | ||
|
|
577b958c4d | ||
|
|
ce38d8ced6 | ||
|
|
d65fcf286d | ||
|
|
5a6a0b37d6 | ||
|
|
4a0a65d604 | ||
|
|
d25abfc305 | ||
|
|
0905e3ce32 | ||
|
|
ac84b272c5 | ||
|
|
e8a63abfa4 | ||
|
|
3fa2745c37 | ||
|
|
775065715d | ||
|
|
4e608b13ae | ||
|
|
886cc68051 | ||
|
|
146a314d22 | ||
|
|
18cf1bce36 | ||
|
|
f7e3f4e589 | ||
|
|
9f9765b78d | ||
|
|
8ae1a7da32 | ||
|
|
08ea3fe225 | ||
|
|
b81be6b4fc | ||
|
|
f1aca0fc46 | ||
|
|
d88fe99780 | ||
|
|
360a1384f2 | ||
|
|
d089b00ad5 | ||
|
|
c05a2adc38 | ||
|
|
7631242621 | ||
|
|
df48c3e718 | ||
|
|
9a755e64b2 | ||
|
|
34d362a003 | ||
|
|
b75cce5d41 | ||
|
|
a07faca2d9 | ||
|
|
8a1a715dc4 | ||
|
|
60a192b1b6 | ||
|
|
3b721e0365 | ||
|
|
3e16c20fce | ||
|
|
ec2c39e82f | ||
|
|
23d319247f | ||
|
|
c2c488410f | ||
|
|
8ea49e76db | ||
|
|
d834ecec6a | ||
|
|
f3000a89d4 | ||
|
|
aa2774a5d5 | ||
|
|
f9630fa13b | ||
|
|
e0cbf77dae | ||
|
|
447f8fba20 | ||
|
|
e60ea1765c | ||
|
|
2d15c23681 | ||
|
|
c2f76d81ab | ||
|
|
8b737cad21 | ||
|
|
fd968d749e | ||
|
|
32a021096b | ||
|
|
3c819ec781 | ||
|
|
01e64a2b69 | ||
|
|
5e08c8bd98 | ||
|
|
24aa4af7c2 | ||
|
|
b3c323ede3 | ||
|
|
3ec1e9184b | ||
|
|
5055f87034 | ||
|
|
3bb43b266f | ||
|
|
c2094a9fc4 | ||
|
|
b82878130c | ||
|
|
8fbd3569ce | ||
|
|
494381b272 | ||
|
|
7422b10a3d | ||
|
|
e4b5591582 | ||
|
|
557a284afd | ||
|
|
75eb2660ce | ||
|
|
34e13c5e5a | ||
|
|
d098372913 | ||
|
|
7e8746c01b | ||
|
|
93d3d8b084 | ||
|
|
98273ddad9 | ||
|
|
c408c53598 | ||
|
|
cde73c5a2b | ||
|
|
d7eb95a2ee | ||
|
|
a2f8877810 | ||
|
|
5779dda937 | ||
|
|
d597bc40a2 | ||
|
|
4a41550cad | ||
|
|
e4fd06482e | ||
|
|
dba03e3a76 | ||
|
|
4b2298e168 | ||
|
|
283badfc7e | ||
|
|
088f2cc269 | ||
|
|
ea40156194 | ||
|
|
0bf48d7a1b | ||
|
|
14f261b1dd | ||
|
|
bec625621a | ||
|
|
19db58907a | ||
|
|
77808d3ae9 | ||
|
|
b2b0d15add | ||
|
|
ecadb301c0 | ||
|
|
360ad7197b | ||
|
|
96ae2235d1 | ||
|
|
37b87e3fde | ||
|
|
5b6714d2c0 | ||
|
|
97c07e91d1 | ||
|
|
7cd7111241 | ||
|
|
4b0306102d | ||
|
|
3f139f2efb | ||
|
|
41a62a1a9e | ||
|
|
8837e617e4 | ||
|
|
2bf410f285 | ||
|
|
04fe43d53a | ||
|
|
643f61e7f4 | ||
|
|
6b91ffecf1 | ||
|
|
4f7f092b9b | ||
|
|
df3c6b7980 | ||
|
|
19839399e5 | ||
|
|
4847be98d2 | ||
|
|
3105320038 | ||
|
|
e8c8b0dbc5 | ||
|
|
c199775c48 | ||
|
|
d2bf7fdaf7 | ||
|
|
621ec274c3 | ||
|
|
7cd73e2710 | ||
|
|
708df4d1e2 | ||
|
|
914a534a3b | ||
|
|
11d18db452 | ||
|
|
00acfe63d4 | ||
|
|
2ac9ab5337 | ||
|
|
2569c9e531 | ||
|
|
946f227226 | ||
|
|
7ead8fdf49 | ||
|
|
f5f554cb3d | ||
|
|
3f2942c599 | ||
|
|
da519e7f73 | ||
|
|
0718ada682 | ||
|
|
f756919dd9 | ||
|
|
406b905dc8 | ||
|
|
91439e0fb0 | ||
|
|
03bd59bff6 | ||
|
|
cf02e1a1aa | ||
|
|
f6d696ea62 | ||
|
|
123acdef23 | ||
|
|
28c7a214dc | ||
|
|
bdae7cd42c | ||
|
|
fc404d0cf7 | ||
|
|
5ce71db048 | ||
|
|
aff98a5b78 | ||
|
|
30cedb13f3 | ||
|
|
0c1ecf7297 | ||
|
|
5390561b58 | ||
|
|
bb457b0f73 | ||
|
|
6276ccf415 | ||
|
|
d3588a057c | ||
|
|
30ce74d6d5 | ||
|
|
ff59b86335 | ||
|
|
e355d20063 | ||
|
|
28ea2444a4 | ||
|
|
e907980ff0 | ||
|
|
5a933a160a | ||
|
|
c7978bcc12 | ||
|
|
5c7a84748b | ||
|
|
8dc9719b99 | ||
|
|
60617c682e | ||
|
|
fd879408f3 | ||
|
|
8decde0370 | ||
|
|
adb5a7d632 | ||
|
|
f07fea2771 | ||
|
|
a2460b7fe7 | ||
|
|
f8f30f41b7 | ||
|
|
60070c2f1e | ||
|
|
3eb25a59dc | ||
|
|
1cbc5d6649 | ||
|
|
bdef410eb2 | ||
|
|
ec9145e61d | ||
|
|
a547c8dd7d | ||
|
|
7996fd8d19 | ||
|
|
7a652518a3 | ||
|
|
ae4426af08 | ||
|
|
91e97b68d4 | ||
|
|
6a08064a52 | ||
|
|
83cfb803a7 | ||
|
|
6d7abb3780 | ||
|
|
50f6cf04f6 | ||
|
|
b162095f89 | ||
|
|
33b485c0c3 | ||
|
|
4893ac3e51 | ||
|
|
76b0197462 | ||
|
|
6a63de2f0f | ||
|
|
e6fb7d9c6a | ||
|
|
0882c0fa97 | ||
|
|
f26fcc0eda | ||
|
|
50c9d056c9 | ||
|
|
5cec3f45f5 | ||
|
|
448f214cdb | ||
|
|
49f2d30587 | ||
|
|
897d5ab089 | ||
|
|
92ff0ddba8 | ||
|
|
1d2ad1f9c9 | ||
|
|
516ebc53ce | ||
|
|
a30b43821f | ||
|
|
d9955d624b | ||
|
|
5345937966 | ||
|
|
580370c3a0 | ||
|
|
c30a5b206e | ||
|
|
053f876e84 | ||
|
|
ab2097960d | ||
|
|
2f23dc72f9 | ||
|
|
f9083d9307 | ||
|
|
25baa57850 | ||
|
|
47b2242c3c | ||
|
|
6099869c59 | ||
|
|
1d861d1d06 | ||
|
|
d1624679ee | ||
|
|
12998bf6f4 | ||
|
|
24394561bd | ||
|
|
4ae87edf37 | ||
|
|
4525bae879 | ||
|
|
dc270303a9 | ||
|
|
a99da85a22 | ||
|
|
e256abfdfb | ||
|
|
fb9011da63 | ||
|
|
68187ba25f | ||
|
|
6c45c8d606 | ||
|
|
9e96cece56 | ||
|
|
1bd44e1e35 | ||
|
|
7badc3e745 | ||
|
|
3af1e92813 | ||
|
|
73718bbd61 | ||
|
|
8f2b4a961f | ||
|
|
9fdeecd996 | ||
|
|
174d89c81f | ||
|
|
71de33d7dd | ||
|
|
9c00eb91d6 | ||
|
|
597583577a | ||
|
|
4e085894d2 | ||
|
|
76a8b0d582 | ||
|
|
27e50aa81a | ||
|
|
aaaceebd91 | ||
|
|
1322ce866e | ||
|
|
78b529fc23 | ||
|
|
9aa0bf7245 | ||
|
|
287bb638a0 | ||
|
|
18ac9210cb | ||
|
|
17dad8313e | ||
|
|
63f3512829 | ||
|
|
1b200fb20b | ||
|
|
8a091c9e02 | ||
|
|
c3c517a200 | ||
|
|
c5b404baa8 | ||
|
|
77cab13827 | ||
|
|
31b2eed1f9 | ||
|
|
e23720a977 | ||
|
|
90058ee175 | ||
|
|
5c6dbdd98f | ||
|
|
8c2d246a53 | ||
|
|
0d26b6426f | ||
|
|
b9fb5c8b44 | ||
|
|
e518e41f67 | ||
|
|
7a38e44e64 | ||
|
|
64d3c55d62 | ||
|
|
89be26a33a | ||
|
|
5b5e2c3567 | ||
|
|
ef59a5ee11 | ||
|
|
b0b3e3668f | ||
|
|
42ace29db4 | ||
|
|
03992c43d9 | ||
|
|
e342a20fde | ||
|
|
3c28db0800 | ||
|
|
8f88152532 | ||
|
|
7a1311984f | ||
|
|
a9f594d6b2 | ||
|
|
5f1835c546 | ||
|
|
2359cceb64 | ||
|
|
a0a1c5d4c0 | ||
|
|
14d65fdf28 | ||
|
|
5fd9570368 | ||
|
|
c753fbff2d | ||
|
|
cdf7165ab4 | ||
|
|
893acd2f02 | ||
|
|
34aaa44b1f | ||
|
|
f2461a2fea | ||
|
|
bb2ecb8a97 | ||
|
|
439be48f36 | ||
|
|
750c8a9810 | ||
|
|
e11b40c94b | ||
|
|
be51fb9ba9 | ||
|
|
e1fca86cf8 | ||
|
|
1fa342b085 | ||
|
|
d146d90131 | ||
|
|
d5bdac02b7 | ||
|
|
dfd7cbf0c5 | ||
|
|
88a4a589a0 | ||
|
|
bead81b64b | ||
|
|
16d5b453a7 | ||
|
|
48906de873 | ||
|
|
9a64b8c5ce | ||
|
|
6ba2b7f8c3 | ||
|
|
6f7ca082f2 | ||
|
|
eb09be3594 | ||
|
|
9d654b7e3b | ||
|
|
8f7fcd4889 | ||
|
|
b85887241f | ||
|
|
5110676c76 | ||
|
|
0020e6c3d3 | ||
|
|
6e6fd0e9bc | ||
|
|
85c26f9740 | ||
|
|
9057ce0ce3 | ||
|
|
378cc91a4d | ||
|
|
cdde38fdc9 | ||
|
|
c34c00baa4 | ||
|
|
9bd535752e | ||
|
|
ecb22642af | ||
|
|
17ccfdc266 | ||
|
|
4633f12972 | ||
|
|
1f6c99635e | ||
|
|
4e92cac171 | ||
|
|
3b88630b0d | ||
|
|
e6d2d8f455 | ||
|
|
84c2d70d9a | ||
|
|
d408f7cabc | ||
|
|
72ae076ce7 | ||
|
|
277f21db7a | ||
|
|
9edd55961f | ||
|
|
9ad6959559 | ||
|
|
37a9d94865 | ||
|
|
e8f5bc1c96 | ||
|
|
8bb236411d | ||
|
|
332f955159 | ||
|
|
e7131eddc2 | ||
|
|
8c07a2c930 | ||
|
|
2fe51d087f | ||
|
|
b1f729a970 | ||
|
|
754e0a0de4 | ||
|
|
7abe7fe304 | ||
|
|
8a552e3639 | ||
|
|
743501addc | ||
|
|
6125e59ce3 | ||
|
|
1d8a0b2940 | ||
|
|
2a0ed7faa2 | ||
|
|
ad17c7fd21 | ||
|
|
4d17366662 | ||
|
|
5e2702090c | ||
|
|
f8d1e4edf3 | ||
|
|
04a3f78605 | ||
|
|
ea1e074083 | ||
|
|
199a6df65e | ||
|
|
c9ebf69e0d | ||
|
|
a36e6259f1 | ||
|
|
de4014f02c | ||
|
|
774457b362 | ||
|
|
7a8048a8fd | ||
|
|
fa49fef695 | ||
|
|
faac2540bf | ||
|
|
4e1eb78163 | ||
|
|
46829487d6 | ||
|
|
8fd021e757 | ||
|
|
a3af953683 | ||
|
|
f27da5cc78 | ||
|
|
23f0b720de | ||
|
|
f66d8e9a61 | ||
|
|
8499c2fd24 | ||
|
|
ea4c4dcc0c | ||
|
|
88e8e2408b | ||
|
|
e5815ae5a2 | ||
|
|
387f79ceae | ||
|
|
bae1259aba | ||
|
|
4ac1d91c16 | ||
|
|
81b8f3fc0e | ||
|
|
8541c87c97 | ||
|
|
0e4314ad1e | ||
|
|
6b44f217a3 | ||
|
|
76760e1bf3 | ||
|
|
d313a74266 | ||
|
|
a535ca31a8 | ||
|
|
da0bb80fb4 | ||
|
|
fb9026d12d | ||
|
|
4ae36ac727 | ||
|
|
ffab3a43fc | ||
|
|
e38d04c655 | ||
|
|
1923d6b1bc | ||
|
|
608a38f873 | ||
|
|
604ab79af9 | ||
|
|
4a43a6ae13 | ||
|
|
e9e0861eb7 | ||
|
|
477028a025 | ||
|
|
b90dcfb041 | ||
|
|
1790a389c7 | ||
|
|
deed9de3e7 | ||
|
|
9e748332dc | ||
|
|
749c2435ed | ||
|
|
6360609980 | ||
|
|
fed60ca61a | ||
|
|
f18f9e2dce | ||
|
|
e1b26bc76f | ||
|
|
2aada8f683 | ||
|
|
f9f386fa19 | ||
|
|
507a9a53ef | ||
|
|
c1ae637fa7 | ||
|
|
f967444ac2 | ||
|
|
c879307b8e | ||
|
|
c8ca3e643d | ||
|
|
9a648efa70 | ||
|
|
f45410c917 | ||
|
|
ec3f168a09 | ||
|
|
a9b35de7ee | ||
|
|
125d053b61 | ||
|
|
585cbf95a6 | ||
|
|
909565e5d9 | ||
|
|
a79423534c | ||
|
|
7a6fb5e35b | ||
|
|
6af34b66fb | ||
|
|
2974f7d11f | ||
|
|
edc0c89753 | ||
|
|
b1ff55dd06 | ||
|
|
f4b5422f66 | ||
|
|
d4ebace99f | ||
|
|
95e09c8e2a | ||
|
|
4623d59206 | ||
|
|
e68b1ad428 | ||
|
|
072e2ece15 | ||
|
|
11130037fe | ||
|
|
ba66ef14cc | ||
|
|
8aacc23882 | ||
|
|
03e5fd3dae | ||
|
|
da52598c08 | ||
|
|
52389731eb | ||
|
|
21864f6f95 | ||
|
|
00f8625280 | ||
|
|
c34e29c712 | ||
|
|
e0ae3359f1 | ||
|
|
c2666bacd7 | ||
|
|
4eefd9c3ce | ||
|
|
211456242e | ||
|
|
6f244c4661 | ||
|
|
47bf6d724b | ||
|
|
5c710ad032 | ||
|
|
dda5a05cbb | ||
|
|
e0a63e0290 | ||
|
|
9246659589 | ||
|
|
377cdb84b4 | ||
|
|
0e759f25fd | ||
|
|
b408bb4f6e | ||
|
|
1356479415 | ||
|
|
ec5b4e704f | ||
|
|
aa9e617510 | ||
|
|
ecb739ce96 | ||
|
|
3b72140435 | ||
|
|
27a6770569 | ||
|
|
2ff611167a | ||
|
|
e83e178b63 | ||
|
|
068a757373 | ||
|
|
0ad4527719 | ||
|
|
8c6327d024 | ||
|
|
aecbb2ab02 | ||
|
|
52b11083fe | ||
|
|
a8c87ce54b | ||
|
|
ddb3240591 | ||
|
|
f25ef639f2 | ||
|
|
ab7d3ce4aa | ||
|
|
50db922cef | ||
|
|
a2708edc37 | ||
|
|
603a5005e2 | ||
|
|
b4f68bce76 | ||
|
|
a76cec1539 | ||
|
|
694e6bcae3 | ||
|
|
b85b18cf5f | ||
|
|
04c707f874 | ||
|
|
99142fd662 | ||
|
|
0c5cb17d96 | ||
|
|
cabde313b5 | ||
|
|
8f68bb342d | ||
|
|
fab75d3a32 | ||
|
|
d19bf98dc4 | ||
|
|
b0f41c0360 | ||
|
|
6ebd60feaa | ||
|
|
dd6007b309 | ||
|
|
fde203379d | ||
|
|
fcb3efee01 | ||
|
|
19a21099ed | ||
|
|
20ca7e71c7 | ||
|
|
002202ff5f | ||
|
|
32487137e8 | ||
|
|
f327ab30a6 | ||
|
|
e7545cbc28 | ||
|
|
eba757d2cd | ||
|
|
4119763e23 | ||
|
|
e830a6d6f5 | ||
|
|
704cd97f21 | ||
|
|
47a0dd696f | ||
|
|
c64791e3a8 | ||
|
|
e82d50a3c5 | ||
|
|
0a7aa9e3e2 | ||
|
|
13ca134d12 | ||
|
|
8671e9a391 | ||
|
|
a7de89f45c | ||
|
|
e9f51e3302 | ||
|
|
5491f8c459 | ||
|
|
de71677208 | ||
|
|
653ee2b625 | ||
|
|
62694b1ce7 | ||
|
|
9c0ad2b825 | ||
|
|
88b529593f | ||
|
|
0351698ef7 | ||
|
|
984df75f83 | ||
|
|
402a8fb967 | ||
|
|
45e3027f81 | ||
|
|
1d655a07cd | ||
|
|
c5e768ffe3 | ||
|
|
8cc6f10634 | ||
|
|
aeac83d643 | ||
|
|
95efcf6803 | ||
|
|
44a78cc821 | ||
|
|
e0918a7a89 | ||
|
|
b52310f641 | ||
|
|
e3219ba452 | ||
|
|
7079c17a0f | ||
|
|
3b8450036a | ||
|
|
defdf34e60 | ||
|
|
6827368e60 | ||
|
|
a409167f64 | ||
|
|
a076b9257d | ||
|
|
7e772b4ee9 | ||
|
|
955a86803f | ||
|
|
d5bacaba63 | ||
|
|
3069deb019 | ||
|
|
7f4bf71807 | ||
|
|
f3e00b6d62 | ||
|
|
feef0f484d | ||
|
|
9adbd4031f | ||
|
|
e0d3101066 | ||
|
|
485387ebbe | ||
|
|
9ac628f020 | ||
|
|
07664c4d54 | ||
|
|
d3dbdb4491 | ||
|
|
90ee9ffe36 | ||
|
|
15e6383aad | ||
|
|
2a0d0b4224 | ||
|
|
02fd75c018 | ||
|
|
a87fec0cbd | ||
|
|
11842d396a | ||
|
|
72854cde44 | ||
|
|
b71c8005e7 | ||
|
|
0994afa25b | ||
|
|
7d5693e0fb | ||
|
|
feaed7ea00 | ||
|
|
8340371f9c | ||
|
|
824caaffd0 | ||
|
|
c0b3fa9ff7 | ||
|
|
e809b9328b | ||
|
|
53defd3108 | ||
|
|
a166dc77bc | ||
|
|
68ed208613 | ||
|
|
8f71dac417 | ||
|
|
5f24da7e18 | ||
|
|
4e61f1f23c | ||
|
|
cbfcaeba8b | ||
|
|
9a8abeac28 | ||
|
|
b0f42466f0 | ||
|
|
bcd7d62d0b | ||
|
|
703f5a22fd | ||
|
|
1ee8e339af | ||
|
|
dffde64079 | ||
|
|
17bc184e28 | ||
|
|
0ba9ee0695 | ||
|
|
c40214e20f | ||
|
|
a3aac3d737 | ||
|
|
7bbe62019a | ||
|
|
b898b9d9e6 | ||
|
|
b217372fea | ||
|
|
b2d2c8e596 | ||
|
|
68e37b8f9a | ||
|
|
fa2d7797f4 | ||
|
|
1885dab066 | ||
|
|
9425f5b772 | ||
|
|
83ed3c8b50 | ||
|
|
f4690e296d | ||
|
|
68c350b4c0 | ||
|
|
da0207f5cb | ||
|
|
2455f1158f | ||
|
|
1031fc4923 | ||
|
|
6beaacb905 | ||
|
|
c46ee7c420 | ||
|
|
227f0bce3d | ||
|
|
611e1c2b19 | ||
|
|
5f974b7457 | ||
|
|
3ef35105c8 | ||
|
|
ec768a2e89 | ||
|
|
b580d3c25a | ||
|
|
ce14f190fb | ||
|
|
4e3da005d4 | ||
|
|
0d9967e8d8 | ||
|
|
2624a0a7ea | ||
|
|
8755d5cbc0 | ||
|
|
abb6d7fbdb | ||
|
|
fc04192c99 | ||
|
|
d4110d3b2a | ||
|
|
05c1751d29 | ||
|
|
6ad042b349 | ||
|
|
e52d8b4dbd | ||
|
|
f288e3469c | ||
|
|
5bb87c6da5 | ||
|
|
03768a5f90 | ||
|
|
a84366368f | ||
|
|
29e6a10e42 | ||
|
|
febd280fba | ||
|
|
73964b374c | ||
|
|
bad6a4b211 | ||
|
|
57d3c52df9 | ||
|
|
d309de2557 | ||
|
|
d5d56ede8b | ||
|
|
6613c29652 | ||
|
|
1a6de25ab6 | ||
|
|
b62c1364a9 | ||
|
|
b59162737d | ||
|
|
543dcb27d8 | ||
|
|
22941168cd | ||
|
|
33dc845de8 | ||
|
|
be0f23beb3 | ||
|
|
b76f2163a4 | ||
|
|
04aa471526 | ||
|
|
b756a67c2a | ||
|
|
a76ee010eb | ||
|
|
eb1fef1f92 | ||
|
|
e498cc7d48 | ||
|
|
a26abe079e | ||
|
|
199b6bdabb | ||
|
|
e4bc7bd1cd | ||
|
|
20651df307 | ||
|
|
f857933748 | ||
|
|
efe2b7c539 | ||
|
|
e090153d93 | ||
|
|
5088b02bfe | ||
|
|
57a716b57a | ||
|
|
1b51714f3b | ||
|
|
cb3d35faf9 | ||
|
|
a0c83b4854 | ||
|
|
1b3ee0e94f | ||
|
|
552a6e7f1c | ||
|
|
38bfb1087b | ||
|
|
2dc55873f0 | ||
|
|
4b1898bfaf | ||
|
|
125bf6f270 | ||
|
|
1873c52aa6 | ||
|
|
ec1e113b4c | ||
|
|
347efac0cd | ||
|
|
b7b5bf58aa | ||
|
|
a324c97815 | ||
|
|
f263a0bc91 | ||
|
|
6a9299018c | ||
|
|
ee471a48bd | ||
|
|
879d7c23b7 | ||
|
|
934b09238e | ||
|
|
1fd8e4435e | ||
|
|
50fd42d0c2 | ||
|
|
399958c881 | ||
|
|
78c93d7e39 | ||
|
|
e3b8a60584 | ||
|
|
b7263edfd0 | ||
|
|
1ee749b352 | ||
|
|
f93734f9e3 | ||
|
|
e211dfa1c2 | ||
|
|
0f7deb1d2a | ||
|
|
f2cb16a5be | ||
|
|
98477e27aa | ||
|
|
4149db1a01 | ||
|
|
9ac921380f | ||
|
|
286e24629f | ||
|
|
ab2efc0c5c | ||
|
|
60d6078e1f | ||
|
|
f94492b2d3 | ||
|
|
f03bb61747 | ||
|
|
dc4e8bae98 | ||
|
|
ac26f8be8b | ||
|
|
8c79499573 | ||
|
|
63fbcc5fc8 | ||
|
|
cad217af19 | ||
|
|
a6ad4a8293 | ||
|
|
503999cb32 | ||
|
|
c2d8f2443e | ||
|
|
4571ed7e2f | ||
|
|
ef5cbd3ba3 | ||
|
|
5c162bd7ce | ||
|
|
7bdaaa25c1 | ||
|
|
9a5a02b654 | ||
|
|
4fea6b6e9b | ||
|
|
bd8b8822ac | ||
|
|
0a44c3ec49 | ||
|
|
3262984386 | ||
|
|
180265c8f4 | ||
|
|
a9b4d33cd2 | ||
|
|
5dfb9b28f7 | ||
|
|
ec75793ac3 | ||
|
|
cd4da36863 | ||
|
|
1749e22569 | ||
|
|
0cce88cfbc | ||
|
|
61e83a300b | ||
|
|
136a13aac7 | ||
|
|
2c90db9ae7 | ||
|
|
507e051a5a | ||
|
|
b5bf9ed1d7 | ||
|
|
215eb7e473 | ||
|
|
f42233699a | ||
|
|
1bec68df4d | ||
|
|
d8576e72eb | ||
|
|
7265468e8d | ||
|
|
d07f36dedd | ||
|
|
364a1b71ec | ||
|
|
daee6d210f | ||
|
|
96be0071e6 | ||
|
|
ff8e1dfb47 | ||
|
|
d26db6f213 | ||
|
|
bb6c753583 | ||
|
|
ca08e4b950 | ||
|
|
5a6b02dbd3 | ||
|
|
14416b1050 | ||
|
|
da4e6fc532 | ||
|
|
57d8b69a6d | ||
|
|
c9d8a8661c | ||
|
|
4a3d23e0e6 | ||
|
|
a3666f2ae5 | ||
|
|
c3e000e574 | ||
|
|
dd5481930a | ||
|
|
842328c661 | ||
|
|
8f75384e2e | ||
|
|
193faa00ce | ||
|
|
5e5383b399 | ||
|
|
cb6b29dbe3 | ||
|
|
82b0819051 | ||
|
|
e12ab4afa4 | ||
|
|
1416f631cc | ||
|
|
dbaac47d1e | ||
|
|
cf0ae5e31b | ||
|
|
8891f07362 | ||
|
|
d78974ec59 | ||
|
|
32be26c4d7 | ||
|
|
9de49aa419 | ||
|
|
294a67a4b4 | ||
|
|
0e99888926 | ||
|
|
74cbf10930 | ||
|
|
08d2909b0e | ||
|
|
0949b11436 | ||
|
|
9cdffe7f63 | ||
|
|
8b2a883669 | ||
|
|
b7fc96100c | ||
|
|
63cbc00a40 | ||
|
|
57b94dba6f | ||
|
|
0dd188e108 | ||
|
|
bf8c840293 | ||
|
|
c0244f3018 | ||
|
|
8af8502202 | ||
|
|
42eaeb92f0 | ||
|
|
7f35eb8867 | ||
|
|
785569c40c | ||
|
|
a9eb70a881 | ||
|
|
5d3d0c8625 | ||
|
|
7e32feeea3 | ||
|
|
0d1935e757 | ||
|
|
9b3ee018e9 | ||
|
|
1de411ec89 | ||
|
|
3192799bbf | ||
|
|
2c8dded52f | ||
|
|
06111ac6cf | ||
|
|
d83294efa7 | ||
|
|
be550ff6fb | ||
|
|
dd55409209 | ||
|
|
e267714d44 | ||
|
|
7c30c4a169 | ||
|
|
4882366ffc | ||
|
|
5f73c245fc | ||
|
|
21ffc0fc54 | ||
|
|
e95a41cf93 | ||
|
|
04771fa4f0 | ||
|
|
2639796255 | ||
|
|
4ebabc1208 | ||
|
|
ce34b60712 | ||
|
|
54094c6331 | ||
|
|
3986f6f11a | ||
|
|
5662da6f7d | ||
|
|
33a75fb2cb | ||
|
|
ee9bcb84b7 | ||
|
|
b5269e9aa4 | ||
|
|
00a6ac3a52 | ||
|
|
ea8a14b003 | ||
|
|
414ab86422 | ||
|
|
d4e2698ae0 | ||
|
|
3f8e3082c0 | ||
|
|
0f738935ee | ||
|
|
9c57976252 | ||
|
|
3e08acf381 | ||
|
|
113259bc15 | ||
|
|
61afe76eae | ||
|
|
08b3b3ecf5 | ||
|
|
bc61221ec6 | ||
|
|
2f0b81e12c | ||
|
|
bb9a6bcd2e | ||
|
|
c8b7ef1016 | ||
|
|
e00467c2a2 | ||
|
|
0eb6150e95 | ||
|
|
91d977479d | ||
|
|
cd761db170 | ||
|
|
026011323e | ||
|
|
adc5f3a07d | ||
|
|
69940374e1 | ||
|
|
6dc461609b | ||
|
|
58d460678e | ||
|
|
0f7fd48cdd | ||
|
|
18de035b4d | ||
|
|
11fa43f0a4 | ||
|
|
91a8fc91d6 | ||
|
|
15bde56551 | ||
|
|
d744e086ef | ||
|
|
378fa5d5c4 | ||
|
|
8349774c5c | ||
|
|
34795b598a | ||
|
|
efd5004330 | ||
|
|
c799531105 | ||
|
|
5c1ded1fe9 | ||
|
|
b2162bb8e6 | ||
|
|
f1769a8d00 | ||
|
|
f520c1d9f2 | ||
|
|
910369a7f8 | ||
|
|
dbf6b6f935 | ||
|
|
e9c463c897 | ||
|
|
f4e43ca9e0 | ||
|
|
a298be9c41 | ||
|
|
18bcaa85a2 | ||
|
|
359f45d50f | ||
|
|
f5c574c37a | ||
|
|
f75a1ae117 | ||
|
|
768ccffe72 | ||
|
|
f6668997e6 | ||
|
|
db11c620a7 | ||
|
|
da48af60dc | ||
|
|
19faaa4104 | ||
|
|
628252896e | ||
|
|
f28aff6f9a | ||
|
|
894732be47 | ||
|
|
051518e72a | ||
|
|
b7b78dead3 | ||
|
|
d1167027f4 | ||
|
|
445c9b22d6 | ||
|
|
67e8877143 | ||
|
|
1fe8024b43 | ||
|
|
8e14e463e4 | ||
|
|
b8666b2562 | ||
|
|
57afdfda6f | ||
|
|
738c21c625 | ||
|
|
41898ed640 | ||
|
|
1ebc9e2ec0 | ||
|
|
9466d5274e | ||
|
|
a53bcb4697 | ||
|
|
8c5592e406 | ||
|
|
41055cd963 | ||
|
|
43874b1d28 | ||
|
|
b570aa2ec6 | ||
|
|
c43233120a | ||
|
|
57a571cc11 | ||
|
|
8622cb6204 | ||
|
|
90417e0022 | ||
|
|
96b941ed35 | ||
|
|
1832bac1a3 | ||
|
|
86641223c1 | ||
|
|
cc770418f2 | ||
|
|
513e361764 | ||
|
|
ddf7fdccc7 | ||
|
|
3df2dbe051 | ||
|
|
3d1d6908c8 | ||
|
|
7474c27372 | ||
|
|
bb0948154d | ||
|
|
fa2816822b | ||
|
|
5a42c70675 | ||
|
|
949527f9cb | ||
|
|
1a1b7e9cf4 | ||
|
|
edacb17171 | ||
|
|
33fd9de281 | ||
|
|
a126dee068 | ||
|
|
e2b942139a | ||
|
|
823b17c386 | ||
|
|
05d1b2129a | ||
|
|
436c0a4104 | ||
|
|
96f469c737 | ||
|
|
4f77abac4f | ||
|
|
d5cd95c7fb | ||
|
|
a2fbf856ff | ||
|
|
4fa8c43266 | ||
|
|
992841a951 | ||
|
|
eb3c3d6bf2 | ||
|
|
39847c5502 | ||
|
|
130232b457 | ||
|
|
ca8ffe583d | ||
|
|
563794ab83 | ||
|
|
9443861849 | ||
|
|
cbf4bbbca8 | ||
|
|
9e353ebb8e | ||
|
|
9183e8f9c9 | ||
|
|
0bb657d2c8 | ||
|
|
992f192529 | ||
|
|
1c9409cac9 | ||
|
|
005a143e3e | ||
|
|
8732974857 | ||
|
|
1ac8349bd4 | ||
|
|
2b9fa89050 | ||
|
|
23ea3c0efc | ||
|
|
698d27aada | ||
|
|
3a46c9fd3e | ||
|
|
9507300939 | ||
|
|
0d6db291de | ||
|
|
d218dec826 | ||
|
|
3d5c277c31 | ||
|
|
a9435dc6bb | ||
|
|
8f307c226b | ||
|
|
4b8f990960 | ||
|
|
3a5a4b89ee | ||
|
|
1485882642 | ||
|
|
2e4f5a64b3 | ||
|
|
90f80ce1c1 | ||
|
|
78904151b0 | ||
|
|
9d4bd6eebd | ||
|
|
5c56dc0357 | ||
|
|
c7810823e8 | ||
|
|
902d03d447 | ||
|
|
b7621a0923 | ||
|
|
b7baaed391 | ||
|
|
9dac7d9cc3 | ||
|
|
1eefe23f11 | ||
|
|
207a76d1b5 | ||
|
|
01df35f215 | ||
|
|
bedf746f1d | ||
|
|
b91a7ac6fb | ||
|
|
79e6beeec3 | ||
|
|
dae9d4c575 | ||
|
|
04928bd83d | ||
|
|
0f3818e711 | ||
|
|
0f1dc6e19c | ||
|
|
ffd0c8b341 | ||
|
|
6220963195 | ||
|
|
20119e3162 | ||
|
|
4cb8fa3cdd | ||
|
|
93e8613da7 | ||
|
|
f9cc19e150 | ||
|
|
0f1c119c76 | ||
|
|
4c734b467f | ||
|
|
1f966ee705 | ||
|
|
172ad4e57d | ||
|
|
3f935aac13 | ||
|
|
9928639ce2 | ||
|
|
0fc722cb28 | ||
|
|
4edca0ce54 | ||
|
|
70942eda8c | ||
|
|
adcb2f59ca | ||
|
|
29b34ca9fd | ||
|
|
d97ee5d209 | ||
|
|
c2bd9df0f7 | ||
|
|
112bfe0933 | ||
|
|
96b500679d | ||
|
|
258ea10c52 | ||
|
|
043ba418ec | ||
|
|
894a8571ee | ||
|
|
874197d940 | ||
|
|
d3ed40cd4d | ||
|
|
a29ba4a6c4 | ||
|
|
fe06fe075e | ||
|
|
de58cb03da | ||
|
|
3204680662 | ||
|
|
07e896508c | ||
|
|
2d3faea713 | ||
|
|
7c89a83d19 | ||
|
|
16f8b41cb9 | ||
|
|
7d506990f5 | ||
|
|
aadcb4c903 | ||
|
|
daf94fcdb2 | ||
|
|
1cef659b78 | ||
|
|
25381ef2c2 | ||
|
|
5927926314 | ||
|
|
2a11d9fec3 | ||
|
|
82c44aaa22 | ||
|
|
a7b483e4b7 | ||
|
|
917335ec54 | ||
|
|
6e59ee2926 | ||
|
|
3c9270d802 | ||
|
|
c4bbcf9890 | ||
|
|
8dbecf3d57 | ||
|
|
0de1369ec5 | ||
|
|
fa95ae4b24 | ||
|
|
2065246186 | ||
|
|
ca1b3df45b | ||
|
|
3bcc86f539 | ||
|
|
218f28912e | ||
|
|
b9642a482f | ||
|
|
33ae68c756 | ||
|
|
62942704bd | ||
|
|
fe81053521 | ||
|
|
222c8aa0ae | ||
|
|
845000d10f | ||
|
|
b05f81b4b4 | ||
|
|
6c1dc5f645 | ||
|
|
5578ccd578 | ||
|
|
78637c96a7 | ||
|
|
f3ec82962e | ||
|
|
4f590cdf7b | ||
|
|
46613adceb | ||
|
|
e1a1cd1067 | ||
|
|
7c8d102c17 | ||
|
|
35d30442f7 | ||
|
|
4f71073d17 | ||
|
|
e142283e64 | ||
|
|
de3707af4a | ||
|
|
2e0769c90e | ||
|
|
1ded7b2fd4 | ||
|
|
cacab68b77 | ||
|
|
728d249202 | ||
|
|
d1823a21ea | ||
|
|
6282efb13c | ||
|
|
0fdc14bc42 | ||
|
|
0370e669e5 | ||
|
|
ccea6bcf51 | ||
|
|
8d9454ea3b | ||
|
|
1ca8d3e4a8 | ||
|
|
9815306875 | ||
|
|
d7736950cd | ||
|
|
f5e3677ef1 | ||
|
|
144d612c52 | ||
|
|
3acbe9ece1 | ||
|
|
7d0b701a2d | ||
|
|
f91537fb48 | ||
|
|
3c5ec49dbe | ||
|
|
9a37a136a1 | ||
|
|
54a0a5ac00 | ||
|
|
704f14ffcd | ||
|
|
925fb967d3 | ||
|
|
5dd19fccd0 | ||
|
|
781100a571 | ||
|
|
3fb0b57d19 | ||
|
|
f79657b41a | ||
|
|
4a5ba756b6 | ||
|
|
0b3d34ab24 | ||
|
|
aa22b62b41 | ||
|
|
51c4fe8f67 | ||
|
|
26f9720e69 | ||
|
|
1f712d9a87 | ||
|
|
5b4d7c7526 | ||
|
|
a948697f3a | ||
|
|
e3b5451672 | ||
|
|
6c69f590cf | ||
|
|
c9625e1b35 | ||
|
|
ced93022b6 | ||
|
|
f4b926ebbe | ||
|
|
203d89d1d3 | ||
|
|
4d42814f5d | ||
|
|
d80069385d | ||
|
|
85a0d59f73 | ||
|
|
58f2205304 | ||
|
|
769fbc55a9 | ||
|
|
f43fa612d5 | ||
|
|
5b0de6b6c7 | ||
|
|
ac8a206d46 | ||
|
|
6896d631db | ||
|
|
6f2e1c2a7e | ||
|
|
ffe0221deb | ||
|
|
18e8d50768 | ||
|
|
81b9a53a37 | ||
|
|
b6ab91fe4b | ||
|
|
f26cda07db | ||
|
|
ecc3094c70 | ||
|
|
17b3ee6eaf | ||
|
|
284e7797c5 | ||
|
|
62ce42440b | ||
|
|
7b755408fa | ||
|
|
ed721dd0c1 | ||
|
|
1a5d22ca78 | ||
|
|
21dbfd2472 | ||
|
|
472d2d5406 | ||
|
|
3af2b1dc66 | ||
|
|
6cfc3a4667 | ||
|
|
992657750c | ||
|
|
a67688749f | ||
|
|
f735416bda | ||
|
|
e5374eb8b8 | ||
|
|
b83b48629d | ||
|
|
ca6792a8a7 | ||
|
|
7cbd50a2e6 | ||
|
|
d6da3bc899 | ||
|
|
9eaca95277 | ||
|
|
c1b27f79ac | ||
|
|
0705f6e6c0 | ||
|
|
a537d8eb65 | ||
|
|
845a604955 | ||
|
|
7adb673a80 | ||
|
|
72e88bb493 | ||
|
|
089b3f17a7 | ||
|
|
ad30e3264a | ||
|
|
e262c8be9c | ||
|
|
492e3a355e | ||
|
|
1487d323cd | ||
|
|
dd88b2c658 | ||
|
|
46dfc4d4fc | ||
|
|
b0a61be9df | ||
|
|
7c00c9a49d | ||
|
|
1365bd7a0a | ||
|
|
6e5adc7abd | ||
|
|
c97e4866dd | ||
|
|
8444ffa0c7 | ||
|
|
2fb59d39c9 | ||
|
|
b5343a36ff | ||
|
|
d7a0f4cb4c | ||
|
|
77d35b95e2 | ||
|
|
b605fb1032 | ||
|
|
a5231a27cc | ||
|
|
1454bacfdd | ||
|
|
ed4e44b994 | ||
|
|
d36c983461 | ||
|
|
05aa96a335 | ||
|
|
6f2464d4ad | ||
|
|
91185f4f7c | ||
|
|
1371c63a8d | ||
|
|
30b414429f | ||
|
|
ce210cd4ee | ||
|
|
8923b06a49 | ||
|
|
b783eab1e8 | ||
|
|
b972e8c071 | ||
|
|
faeb54224e | ||
|
|
1ba7700283 | ||
|
|
710cf4ebba | ||
|
|
82260d728f | ||
|
|
62e4285924 | ||
|
|
ce78c75999 | ||
|
|
c022c742b5 | ||
|
|
3cb5219e09 | ||
|
|
5d30d16e09 | ||
|
|
4780fd9974 | ||
|
|
3ba0576cf6 | ||
|
|
283d1ab7e8 | ||
|
|
78bc7b8156 | ||
|
|
a07ddb4371 | ||
|
|
4395c608e8 | ||
|
|
f4322242a1 | ||
|
|
a3711eb463 | ||
|
|
6656528d78 | ||
|
|
e1f16c6721 |
210
.dockerignore
Normal file
210
.dockerignore
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.run
|
||||||
|
docs
|
||||||
|
test
|
||||||
|
typings
|
||||||
|
*Client.py
|
||||||
|
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
*_Spoiler.txt
|
||||||
|
*.bmbp
|
||||||
|
*.apbp
|
||||||
|
*.apl2ac
|
||||||
|
*.apm3
|
||||||
|
*.apmc
|
||||||
|
*.apz5
|
||||||
|
*.aptloz
|
||||||
|
*.apemerald
|
||||||
|
*.pyc
|
||||||
|
*.pyd
|
||||||
|
*.sfc
|
||||||
|
*.z64
|
||||||
|
*.n64
|
||||||
|
*.nes
|
||||||
|
*.smc
|
||||||
|
*.sms
|
||||||
|
*.gb
|
||||||
|
*.gbc
|
||||||
|
*.gba
|
||||||
|
*.wixobj
|
||||||
|
*.lck
|
||||||
|
*.db3
|
||||||
|
*multidata
|
||||||
|
*multisave
|
||||||
|
*.archipelago
|
||||||
|
*.apsave
|
||||||
|
*.BIN
|
||||||
|
*.puml
|
||||||
|
|
||||||
|
setups
|
||||||
|
build
|
||||||
|
bundle/components.wxs
|
||||||
|
dist
|
||||||
|
/prof/
|
||||||
|
README.html
|
||||||
|
.vs/
|
||||||
|
EnemizerCLI/
|
||||||
|
/Players/
|
||||||
|
/SNI/
|
||||||
|
/sni-*/
|
||||||
|
/appimagetool*
|
||||||
|
/host.yaml
|
||||||
|
/options.yaml
|
||||||
|
/config.yaml
|
||||||
|
/logs/
|
||||||
|
_persistent_storage.yaml
|
||||||
|
mystery_result_*.yaml
|
||||||
|
*-errors.txt
|
||||||
|
success.txt
|
||||||
|
output/
|
||||||
|
Output Logs/
|
||||||
|
/factorio/
|
||||||
|
/Minecraft Forge Server/
|
||||||
|
/WebHostLib/static/generated
|
||||||
|
/freeze_requirements.txt
|
||||||
|
/Archipelago.zip
|
||||||
|
/setup.ini
|
||||||
|
/installdelete.iss
|
||||||
|
/data/user.kv
|
||||||
|
/datapackage
|
||||||
|
/custom_worlds
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
*.dll
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
installer.log
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# vim editor
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv*
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
/venv*/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
*.code-workspace
|
||||||
|
shell.nix
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# Cython intermediates
|
||||||
|
_speedups.c
|
||||||
|
_speedups.cpp
|
||||||
|
_speedups.html
|
||||||
|
|
||||||
|
# minecraft server stuff
|
||||||
|
jdk*/
|
||||||
|
minecraft*/
|
||||||
|
minecraft_versions.json
|
||||||
|
!worlds/minecraft/
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
#undertale stuff
|
||||||
|
/Undertale/
|
||||||
|
|
||||||
|
# OS General Files
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
Thumbs.db
|
||||||
|
[Dd]esktop.ini
|
||||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +1,2 @@
|
|||||||
worlds/blasphemous/region_data.py linguist-generated=true
|
worlds/blasphemous/region_data.py linguist-generated=true
|
||||||
|
worlds/yachtdice/YachtWeights.py linguist-generated=true
|
||||||
|
|||||||
1
.github/labeler.yml
vendored
1
.github/labeler.yml
vendored
@@ -21,7 +21,6 @@
|
|||||||
- '!data/**'
|
- '!data/**'
|
||||||
- '!.run/**'
|
- '!.run/**'
|
||||||
- '!.github/**'
|
- '!.github/**'
|
||||||
- '!worlds_disabled/**'
|
|
||||||
- '!worlds/**'
|
- '!worlds/**'
|
||||||
- '!WebHost.py'
|
- '!WebHost.py'
|
||||||
- '!WebHostLib/**'
|
- '!WebHostLib/**'
|
||||||
|
|||||||
19
.github/pyright-config.json
vendored
19
.github/pyright-config.json
vendored
@@ -1,8 +1,21 @@
|
|||||||
{
|
{
|
||||||
"include": [
|
"include": [
|
||||||
"type_check.py",
|
"../BizHawkClient.py",
|
||||||
|
"../Patch.py",
|
||||||
|
"../test/param.py",
|
||||||
|
"../test/general/test_groups.py",
|
||||||
|
"../test/general/test_helpers.py",
|
||||||
|
"../test/general/test_memory.py",
|
||||||
|
"../test/general/test_names.py",
|
||||||
|
"../test/multiworld/__init__.py",
|
||||||
|
"../test/multiworld/test_multiworlds.py",
|
||||||
|
"../test/netutils/__init__.py",
|
||||||
|
"../test/programs/__init__.py",
|
||||||
|
"../test/programs/test_multi_server.py",
|
||||||
|
"../test/utils/__init__.py",
|
||||||
|
"../test/webhost/test_descriptions.py",
|
||||||
"../worlds/AutoSNIClient.py",
|
"../worlds/AutoSNIClient.py",
|
||||||
"../Patch.py"
|
"type_check.py"
|
||||||
],
|
],
|
||||||
|
|
||||||
"exclude": [
|
"exclude": [
|
||||||
@@ -16,7 +29,7 @@
|
|||||||
"reportMissingImports": true,
|
"reportMissingImports": true,
|
||||||
"reportMissingTypeStubs": true,
|
"reportMissingTypeStubs": true,
|
||||||
|
|
||||||
"pythonVersion": "3.10",
|
"pythonVersion": "3.11",
|
||||||
"pythonPlatform": "Windows",
|
"pythonPlatform": "Windows",
|
||||||
|
|
||||||
"executionEnvironments": [
|
"executionEnvironments": [
|
||||||
|
|||||||
4
.github/workflows/analyze-modified-files.yml
vendored
4
.github/workflows/analyze-modified-files.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
|||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
if: env.diff != ''
|
if: env.diff != ''
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.11'
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
if: env.diff != ''
|
if: env.diff != ''
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
if: env.diff != '' && matrix.task == 'flake8'
|
if: env.diff != '' && matrix.task == 'flake8'
|
||||||
run: |
|
run: |
|
||||||
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
|
flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }}
|
||||||
|
|
||||||
- name: "flake8: Lint modified files"
|
- name: "flake8: Lint modified files"
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
64
.github/workflows/build.yml
vendored
64
.github/workflows/build.yml
vendored
@@ -9,29 +9,43 @@ on:
|
|||||||
- 'setup.py'
|
- 'setup.py'
|
||||||
- 'requirements.txt'
|
- 'requirements.txt'
|
||||||
- '*.iss'
|
- '*.iss'
|
||||||
|
- 'worlds/*/archipelago.json'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build.yml'
|
- '.github/workflows/build.yml'
|
||||||
- 'setup.py'
|
- 'setup.py'
|
||||||
- 'requirements.txt'
|
- 'requirements.txt'
|
||||||
- '*.iss'
|
- '*.iss'
|
||||||
|
- 'worlds/*/archipelago.json'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
APPIMAGETOOL_VERSION: 13
|
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||||
|
# we check the sha256 and require manual intervention if it was updated.
|
||||||
|
APPIMAGE_FORK: 'PopTracker'
|
||||||
|
APPIMAGETOOL_VERSION: 'r-2025-11-18'
|
||||||
|
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
|
||||||
|
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
|
||||||
|
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
|
||||||
|
|
||||||
|
permissions: # permissions required for attestation
|
||||||
|
id-token: 'write'
|
||||||
|
attestations: 'write'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
|
|
||||||
build-win-py310: # RCs will still be built and signed by hand
|
build-win: # RCs and releases may still be built and signed by hand
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
|
# - copy code below to release.yml -
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install python
|
- name: Install python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '~3.12.7'
|
||||||
|
check-latest: true
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||||
@@ -64,6 +78,18 @@ jobs:
|
|||||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||||
$SETUP_NAME=$contents[0].Name
|
$SETUP_NAME=$contents[0].Name
|
||||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||||
|
# - copy code above to release.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher.exe
|
||||||
|
build/exe.*/ArchipelagoLauncherDebug.exe
|
||||||
|
build/exe.*/ArchipelagoGenerate.exe
|
||||||
|
build/exe.*/ArchipelagoServer.exe
|
||||||
|
dist/${{ env.ZIP_NAME }}
|
||||||
|
setups/${{ env.SETUP_NAME }}
|
||||||
- name: Check build loads expected worlds
|
- name: Check build loads expected worlds
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -80,7 +106,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd build/exe*
|
cd build/exe*
|
||||||
cp Players/Templates/Clique.yaml Players/
|
cp Players/Templates/VVVVVV.yaml Players/
|
||||||
timeout 30 ./ArchipelagoGenerate
|
timeout 30 ./ArchipelagoGenerate
|
||||||
- name: Store 7z
|
- name: Store 7z
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -98,8 +124,8 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7 # keep for 7 days, should be enough
|
retention-days: 7 # keep for 7 days, should be enough
|
||||||
|
|
||||||
build-ubuntu2004:
|
build-ubuntu2204:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
# - copy code below to release.yml -
|
# - copy code below to release.yml -
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -111,14 +137,18 @@ jobs:
|
|||||||
- name: Get a recent python
|
- name: Get a recent python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '~3.12.7'
|
||||||
|
check-latest: true
|
||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||||
|
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
|
||||||
|
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
||||||
|
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -130,7 +160,7 @@ jobs:
|
|||||||
# charset-normalizer was somehow incomplete in the github runner
|
# charset-normalizer was somehow incomplete in the github runner
|
||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
|
||||||
python setup.py build_exe --yes bdist_appimage --yes
|
python setup.py build_exe --yes bdist_appimage --yes
|
||||||
echo -e "setup.py build output:\n `ls build`"
|
echo -e "setup.py build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
@@ -140,6 +170,16 @@ jobs:
|
|||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
# - copy code above to release.yml -
|
# - copy code above to release.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher
|
||||||
|
build/exe.*/ArchipelagoGenerate
|
||||||
|
build/exe.*/ArchipelagoServer
|
||||||
|
dist/${{ env.APPIMAGE_NAME }}*
|
||||||
|
dist/${{ env.TAR_NAME }}
|
||||||
- name: Build Again
|
- name: Build Again
|
||||||
run: |
|
run: |
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
@@ -160,7 +200,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd build/exe*
|
cd build/exe*
|
||||||
cp Players/Templates/Clique.yaml Players/
|
cp Players/Templates/VVVVVV.yaml Players/
|
||||||
timeout 30 ./ArchipelagoGenerate
|
timeout 30 ./ArchipelagoGenerate
|
||||||
- name: Store AppImage
|
- name: Store AppImage
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
8
.github/workflows/ctest.yml
vendored
8
.github/workflows/ctest.yml
vendored
@@ -11,7 +11,7 @@ on:
|
|||||||
- '**.hh?'
|
- '**.hh?'
|
||||||
- '**.hpp'
|
- '**.hpp'
|
||||||
- '**.hxx'
|
- '**.hxx'
|
||||||
- '**.CMakeLists'
|
- '**/CMakeLists.txt'
|
||||||
- '.github/workflows/ctest.yml'
|
- '.github/workflows/ctest.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
@@ -21,7 +21,7 @@ on:
|
|||||||
- '**.hh?'
|
- '**.hh?'
|
||||||
- '**.hpp'
|
- '**.hpp'
|
||||||
- '**.hxx'
|
- '**.hxx'
|
||||||
- '**.CMakeLists'
|
- '**/CMakeLists.txt'
|
||||||
- '.github/workflows/ctest.yml'
|
- '.github/workflows/ctest.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -36,9 +36,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: ilammy/msvc-dev-cmd@v1
|
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
|
||||||
if: startsWith(matrix.os,'windows')
|
if: startsWith(matrix.os,'windows')
|
||||||
- uses: Bacondish2023/setup-googletest@v1
|
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
|
||||||
with:
|
with:
|
||||||
build-type: 'Release'
|
build-type: 'Release'
|
||||||
- name: Build tests
|
- name: Build tests
|
||||||
|
|||||||
154
.github/workflows/docker.yml
vendored
Normal file
154
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
name: Build and Publish Docker Images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "**"
|
||||||
|
- "!docs/**"
|
||||||
|
- "!deploy/**"
|
||||||
|
- "!setup.py"
|
||||||
|
- "!.gitignore"
|
||||||
|
- "!.github/workflows/**"
|
||||||
|
- ".github/workflows/docker.yml"
|
||||||
|
branches:
|
||||||
|
- "main"
|
||||||
|
tags:
|
||||||
|
- "v?[0-9]+.[0-9]+.[0-9]*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
image-name: ${{ steps.image.outputs.name }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
package-name: ${{ steps.package.outputs.name }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set lowercase image name
|
||||||
|
id: image
|
||||||
|
run: |
|
||||||
|
echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set package name
|
||||||
|
id: package
|
||||||
|
run: |
|
||||||
|
echo "name=$(basename ${GITHUB_REPOSITORY,,})" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch,enable={{is_not_default_branch}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=nightly,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Compute final tags
|
||||||
|
id: final-tags
|
||||||
|
run: |
|
||||||
|
readarray -t tags <<< "${{ steps.meta.outputs.tags }}"
|
||||||
|
|
||||||
|
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
|
tag="${{ github.ref_name }}"
|
||||||
|
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
full_latest="${{ env.REGISTRY }}/${{ steps.image.outputs.name }}:latest"
|
||||||
|
# Check if latest is already in tags to avoid duplicates
|
||||||
|
if ! printf '%s\n' "${tags[@]}" | grep -q "^$full_latest$"; then
|
||||||
|
tags+=("$full_latest")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set multiline output
|
||||||
|
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
printf '%s\n' "${tags[@]}" >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
suffix: amd64
|
||||||
|
cache-scope: amd64
|
||||||
|
- platform: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
suffix: arm64
|
||||||
|
cache-scope: arm64
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Compute suffixed tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
readarray -t tags <<< "${{ needs.prepare.outputs.tags }}"
|
||||||
|
suffixed=()
|
||||||
|
for t in "${tags[@]}"; do
|
||||||
|
suffixed+=("$t-${{ matrix.suffix }}")
|
||||||
|
done
|
||||||
|
echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/${{ matrix.platform }}
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.tags.outputs.tags }}
|
||||||
|
labels: ${{ needs.prepare.outputs.labels }}
|
||||||
|
cache-from: type=gha,scope=${{ matrix.cache-scope }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
|
||||||
|
provenance: false
|
||||||
|
|
||||||
|
manifest:
|
||||||
|
needs: [prepare, build]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create and push multi-arch manifest
|
||||||
|
run: |
|
||||||
|
readarray -t tag_array <<< "${{ needs.prepare.outputs.tags }}"
|
||||||
|
|
||||||
|
for tag in "${tag_array[@]}"; do
|
||||||
|
docker manifest create "$tag" \
|
||||||
|
"$tag-amd64" \
|
||||||
|
"$tag-arm64"
|
||||||
|
|
||||||
|
docker manifest push "$tag"
|
||||||
|
done
|
||||||
3
.github/workflows/label-pull-requests.yml
vendored
3
.github/workflows/label-pull-requests.yml
vendored
@@ -6,11 +6,12 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
env:
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
labeler:
|
labeler:
|
||||||
name: 'Apply content-based labels'
|
name: 'Apply content-based labels'
|
||||||
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@v5
|
- uses: actions/labeler@v5
|
||||||
|
|||||||
111
.github/workflows/release.yml
vendored
111
.github/workflows/release.yml
vendored
@@ -5,11 +5,22 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*.*.*'
|
- 'v?[0-9]+.[0-9]+.[0-9]*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
APPIMAGETOOL_VERSION: 13
|
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||||
|
# we check the sha256 and require manual intervention if it was updated.
|
||||||
|
APPIMAGE_FORK: 'PopTracker'
|
||||||
|
APPIMAGETOOL_VERSION: 'r-2025-11-18'
|
||||||
|
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
|
||||||
|
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
|
||||||
|
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
|
||||||
|
|
||||||
|
permissions: # permissions required for attestation
|
||||||
|
id-token: 'write'
|
||||||
|
attestations: 'write'
|
||||||
|
contents: 'write' # additionally required for release
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
create-release:
|
||||||
@@ -26,11 +37,79 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# build-release-windows: # this is done by hand because of signing
|
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
|
|
||||||
build-release-ubuntu2004:
|
build-release-win:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: windows-latest
|
||||||
|
if: ${{ true }} # change to false to skip if release is built by hand
|
||||||
|
needs: create-release
|
||||||
|
steps:
|
||||||
|
- name: Set env
|
||||||
|
shell: bash
|
||||||
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
|
# - code below copied from build.yml -
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '~3.12.7'
|
||||||
|
check-latest: true
|
||||||
|
- 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
|
||||||
|
choco install innosetup --version=6.2.2 --allow-downgrade
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python setup.py build_exe --yes
|
||||||
|
if ( $? -eq $false ) {
|
||||||
|
Write-Error "setup.py failed!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
|
||||||
|
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||||
|
echo "$NAME -> $ZIP_NAME"
|
||||||
|
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
|
||||||
|
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
||||||
|
- name: Build Setup
|
||||||
|
run: |
|
||||||
|
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
||||||
|
if ( $? -eq $false ) {
|
||||||
|
Write-Error "Building setup failed!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||||
|
$SETUP_NAME=$contents[0].Name
|
||||||
|
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||||
|
# - code above copied from build.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher.exe
|
||||||
|
build/exe.*/ArchipelagoLauncherDebug.exe
|
||||||
|
build/exe.*/ArchipelagoGenerate.exe
|
||||||
|
build/exe.*/ArchipelagoServer.exe
|
||||||
|
setups/*
|
||||||
|
- name: Add to Release
|
||||||
|
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||||
|
with:
|
||||||
|
draft: true # see above
|
||||||
|
prerelease: false
|
||||||
|
name: Archipelago ${{ env.RELEASE_VERSION }}
|
||||||
|
files: |
|
||||||
|
setups/*
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
build-release-ubuntu2204:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: create-release
|
||||||
steps:
|
steps:
|
||||||
- name: Set env
|
- name: Set env
|
||||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
@@ -44,14 +123,18 @@ jobs:
|
|||||||
- name: Get a recent python
|
- name: Get a recent python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '~3.12.7'
|
||||||
|
check-latest: true
|
||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||||
|
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
|
||||||
|
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
||||||
|
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -63,7 +146,7 @@ jobs:
|
|||||||
# charset-normalizer was somehow incomplete in the github runner
|
# charset-normalizer was somehow incomplete in the github runner
|
||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
|
||||||
python setup.py build_exe --yes bdist_appimage --yes
|
python setup.py build_exe --yes bdist_appimage --yes
|
||||||
echo -e "setup.py build output:\n `ls build`"
|
echo -e "setup.py build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
@@ -73,6 +156,14 @@ jobs:
|
|||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
# - code above copied from build.yml -
|
# - code above copied from build.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher
|
||||||
|
build/exe.*/ArchipelagoGenerate
|
||||||
|
build/exe.*/ArchipelagoServer
|
||||||
|
dist/*
|
||||||
- name: Add to Release
|
- name: Add to Release
|
||||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||||
with:
|
with:
|
||||||
|
|||||||
6
.github/workflows/scan-build.yml
vendored
6
.github/workflows/scan-build.yml
vendored
@@ -40,10 +40,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
wget https://apt.llvm.org/llvm.sh
|
wget https://apt.llvm.org/llvm.sh
|
||||||
chmod +x ./llvm.sh
|
chmod +x ./llvm.sh
|
||||||
sudo ./llvm.sh 17
|
sudo ./llvm.sh 19
|
||||||
- name: Install scan-build command
|
- name: Install scan-build command
|
||||||
run: |
|
run: |
|
||||||
sudo apt install clang-tools-17
|
sudo apt install clang-tools-19
|
||||||
- name: Get a recent python
|
- name: Get a recent python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
- name: scan-build
|
- name: scan-build
|
||||||
run: |
|
run: |
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
|
scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
|
||||||
- name: Store report
|
- name: Store report
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
2
.github/workflows/strict-type-check.yml
vendored
2
.github/workflows/strict-type-check.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip pyright==1.1.358
|
python -m pip install --upgrade pip pyright==1.1.392.post0
|
||||||
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
||||||
|
|
||||||
- name: "pyright: strict check on specific files"
|
- name: "pyright: strict check on specific files"
|
||||||
|
|||||||
20
.github/workflows/unittests.yml
vendored
20
.github/workflows/unittests.yml
vendored
@@ -8,18 +8,24 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- '**'
|
- '**'
|
||||||
- '!docs/**'
|
- '!docs/**'
|
||||||
|
- '!deploy/**'
|
||||||
- '!setup.py'
|
- '!setup.py'
|
||||||
|
- '!Dockerfile'
|
||||||
- '!*.iss'
|
- '!*.iss'
|
||||||
- '!.gitignore'
|
- '!.gitignore'
|
||||||
|
- '!.dockerignore'
|
||||||
- '!.github/workflows/**'
|
- '!.github/workflows/**'
|
||||||
- '.github/workflows/unittests.yml'
|
- '.github/workflows/unittests.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '**'
|
- '**'
|
||||||
- '!docs/**'
|
- '!docs/**'
|
||||||
|
- '!deploy/**'
|
||||||
- '!setup.py'
|
- '!setup.py'
|
||||||
|
- '!Dockerfile'
|
||||||
- '!*.iss'
|
- '!*.iss'
|
||||||
- '!.gitignore'
|
- '!.gitignore'
|
||||||
|
- '!.dockerignore'
|
||||||
- '!.github/workflows/**'
|
- '!.github/workflows/**'
|
||||||
- '.github/workflows/unittests.yml'
|
- '.github/workflows/unittests.yml'
|
||||||
|
|
||||||
@@ -33,15 +39,15 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest]
|
os: [ubuntu-latest]
|
||||||
python:
|
python:
|
||||||
- {version: '3.10'}
|
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10
|
||||||
- {version: '3.11'}
|
|
||||||
- {version: '3.12'}
|
- {version: '3.12'}
|
||||||
|
- {version: '3.13'}
|
||||||
include:
|
include:
|
||||||
- python: {version: '3.10'} # old compat
|
- python: {version: '3.11'} # old compat
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.12'} # current
|
- python: {version: '3.13'} # current
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.12'} # current
|
- python: {version: '3.13'} # current
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -53,7 +59,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pytest pytest-subtests pytest-xdist
|
pip install -r ci-requirements.txt
|
||||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||||
python Launcher.py --update_settings # make sure host.yaml exists for tests
|
python Launcher.py --update_settings # make sure host.yaml exists for tests
|
||||||
- name: Unittests
|
- name: Unittests
|
||||||
@@ -69,7 +75,7 @@ jobs:
|
|||||||
os:
|
os:
|
||||||
- ubuntu-latest
|
- ubuntu-latest
|
||||||
python:
|
python:
|
||||||
- {version: '3.12'} # current
|
- {version: '3.13'} # current
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -4,11 +4,13 @@
|
|||||||
*_Spoiler.txt
|
*_Spoiler.txt
|
||||||
*.bmbp
|
*.bmbp
|
||||||
*.apbp
|
*.apbp
|
||||||
|
*.apcivvi
|
||||||
*.apl2ac
|
*.apl2ac
|
||||||
*.apm3
|
*.apm3
|
||||||
*.apmc
|
*.apmc
|
||||||
*.apz5
|
*.apz5
|
||||||
*.aptloz
|
*.aptloz
|
||||||
|
*.aptww
|
||||||
*.apemerald
|
*.apemerald
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyd
|
*.pyd
|
||||||
@@ -54,7 +56,6 @@ success.txt
|
|||||||
output/
|
output/
|
||||||
Output Logs/
|
Output Logs/
|
||||||
/factorio/
|
/factorio/
|
||||||
/Minecraft Forge Server/
|
|
||||||
/WebHostLib/static/generated
|
/WebHostLib/static/generated
|
||||||
/freeze_requirements.txt
|
/freeze_requirements.txt
|
||||||
/Archipelago.zip
|
/Archipelago.zip
|
||||||
@@ -182,12 +183,6 @@ _speedups.c
|
|||||||
_speedups.cpp
|
_speedups.cpp
|
||||||
_speedups.html
|
_speedups.html
|
||||||
|
|
||||||
# minecraft server stuff
|
|
||||||
jdk*/
|
|
||||||
minecraft*/
|
|
||||||
minecraft_versions.json
|
|
||||||
!worlds/minecraft/
|
|
||||||
|
|
||||||
# pyenv
|
# pyenv
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
|
|||||||
24
.run/Build APWorld.run.xml
Normal file
24
.run/Build APWorld.run.xml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Build APWorld" type="PythonConfigurationType" factoryName="Python">
|
||||||
|
<module name="Archipelago" />
|
||||||
|
<option name="ENV_FILES" value="" />
|
||||||
|
<option name="INTERPRETER_OPTIONS" value="" />
|
||||||
|
<option name="PARENT_ENVS" value="true" />
|
||||||
|
<envs>
|
||||||
|
<env name="PYTHONUNBUFFERED" value="1" />
|
||||||
|
</envs>
|
||||||
|
<option name="SDK_HOME" value="" />
|
||||||
|
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/" />
|
||||||
|
<option name="IS_MODULE_SDK" value="true" />
|
||||||
|
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||||
|
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||||
|
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/Launcher.py" />
|
||||||
|
<option name="PARAMETERS" value=""Build APWorlds"" />
|
||||||
|
<option name="SHOW_COMMAND_LINE" value="false" />
|
||||||
|
<option name="EMULATE_TERMINAL" value="false" />
|
||||||
|
<option name="MODULE_MODE" value="false" />
|
||||||
|
<option name="REDIRECT_INPUT" value="false" />
|
||||||
|
<option name="INPUT_FILE" value="" />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import sys
|
||||||
from worlds.ahit.Client import launch
|
from worlds.ahit.Client import launch
|
||||||
import Utils
|
import Utils
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
@@ -5,4 +6,4 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("AHITClient", exception_logger="Client")
|
Utils.init_logging("AHITClient", exception_logger="Client")
|
||||||
launch()
|
launch(*sys.argv[1:])
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import List
|
|||||||
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
from settings import get_settings
|
||||||
from NetUtils import ClientStatus
|
from NetUtils import ClientStatus
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||||
@@ -80,8 +81,8 @@ class AdventureContext(CommonContext):
|
|||||||
self.local_item_locations = {}
|
self.local_item_locations = {}
|
||||||
self.dragon_speed_info = {}
|
self.dragon_speed_info = {}
|
||||||
|
|
||||||
options = Utils.get_settings()
|
options = get_settings().adventure_options
|
||||||
self.display_msgs = options["adventure_options"]["display_msgs"]
|
self.display_msgs = options.display_msgs
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
async def server_auth(self, password_requested: bool = False):
|
||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
@@ -102,7 +103,7 @@ class AdventureContext(CommonContext):
|
|||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd == 'Connected':
|
if cmd == 'Connected':
|
||||||
self.locations_array = None
|
self.locations_array = None
|
||||||
if Utils.get_settings()["adventure_options"].get("death_link", False):
|
if get_settings().adventure_options.as_dict().get("death_link", False):
|
||||||
self.set_deathlink = True
|
self.set_deathlink = True
|
||||||
async_start(self.get_freeincarnates_used())
|
async_start(self.get_freeincarnates_used())
|
||||||
elif cmd == "RoomInfo":
|
elif cmd == "RoomInfo":
|
||||||
@@ -406,6 +407,7 @@ async def atari_sync_task(ctx: AdventureContext):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
except CancelledError:
|
except CancelledError:
|
||||||
pass
|
pass
|
||||||
@@ -415,8 +417,9 @@ async def atari_sync_task(ctx: AdventureContext):
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
|
options = get_settings().adventure_options
|
||||||
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
|
auto_start = options.rom_start
|
||||||
|
rom_args = options.rom_args
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
@@ -511,7 +514,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
653
BaseClasses.py
653
BaseClasses.py
@@ -5,12 +5,14 @@ import functools
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
|
import warnings
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque, defaultdict
|
||||||
from collections.abc import Collection, MutableSequence
|
from collections.abc import Collection, MutableSequence
|
||||||
from enum import IntEnum, IntFlag
|
from enum import IntEnum, IntFlag
|
||||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
|
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
|
||||||
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
|
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload)
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
from typing_extensions import NotRequired, TypedDict
|
from typing_extensions import NotRequired, TypedDict
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ import Options
|
|||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from entrance_rando import ERPlacementState
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
|
|
||||||
|
|
||||||
@@ -53,12 +56,21 @@ class HasNameAndPlayer(Protocol):
|
|||||||
player: int
|
player: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class PlandoItemBlock:
|
||||||
|
player: int
|
||||||
|
from_pool: bool
|
||||||
|
force: bool | Literal["silent"]
|
||||||
|
worlds: set[int] = dataclasses.field(default_factory=set)
|
||||||
|
items: list[str] = dataclasses.field(default_factory=list)
|
||||||
|
locations: list[str] = dataclasses.field(default_factory=list)
|
||||||
|
resolved_locations: list[Location] = dataclasses.field(default_factory=list)
|
||||||
|
count: dict[str, int] = dataclasses.field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class MultiWorld():
|
class MultiWorld():
|
||||||
debug_types = False
|
debug_types = False
|
||||||
player_name: Dict[int, str]
|
player_name: Dict[int, str]
|
||||||
plando_texts: List[Dict[str, str]]
|
|
||||||
plando_items: List[List[Dict[str, Any]]]
|
|
||||||
plando_connections: List
|
|
||||||
worlds: Dict[int, "AutoWorld.World"]
|
worlds: Dict[int, "AutoWorld.World"]
|
||||||
groups: Dict[int, Group]
|
groups: Dict[int, Group]
|
||||||
regions: RegionManager
|
regions: RegionManager
|
||||||
@@ -82,6 +94,8 @@ class MultiWorld():
|
|||||||
start_location_hints: Dict[int, Options.StartLocationHints]
|
start_location_hints: Dict[int, Options.StartLocationHints]
|
||||||
item_links: Dict[int, Options.ItemLinks]
|
item_links: Dict[int, Options.ItemLinks]
|
||||||
|
|
||||||
|
plando_item_blocks: Dict[int, List[PlandoItemBlock]]
|
||||||
|
|
||||||
game: Dict[int, str]
|
game: Dict[int, str]
|
||||||
|
|
||||||
random: random.Random
|
random: random.Random
|
||||||
@@ -140,17 +154,11 @@ class MultiWorld():
|
|||||||
self.algorithm = 'balanced'
|
self.algorithm = 'balanced'
|
||||||
self.groups = {}
|
self.groups = {}
|
||||||
self.regions = self.RegionManager(players)
|
self.regions = self.RegionManager(players)
|
||||||
self.shops = []
|
|
||||||
self.itempool = []
|
self.itempool = []
|
||||||
self.seed = None
|
self.seed = None
|
||||||
self.seed_name: str = "Unavailable"
|
self.seed_name: str = "Unavailable"
|
||||||
self.precollected_items = {player: [] for player in self.player_ids}
|
self.precollected_items = {player: [] for player in self.player_ids}
|
||||||
self.required_locations = []
|
self.required_locations = []
|
||||||
self.light_world_light_cone = False
|
|
||||||
self.dark_world_light_cone = False
|
|
||||||
self.rupoor_cost = 10
|
|
||||||
self.aga_randomness = True
|
|
||||||
self.save_and_quit_from_boss = True
|
|
||||||
self.custom = False
|
self.custom = False
|
||||||
self.customitemarray = []
|
self.customitemarray = []
|
||||||
self.shuffle_ganon = True
|
self.shuffle_ganon = True
|
||||||
@@ -159,18 +167,17 @@ class MultiWorld():
|
|||||||
self.local_early_items = {player: {} for player in self.player_ids}
|
self.local_early_items = {player: {} for player in self.player_ids}
|
||||||
self.indirect_connections = {}
|
self.indirect_connections = {}
|
||||||
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
||||||
|
self.plando_item_blocks = {}
|
||||||
|
|
||||||
for player in range(1, players + 1):
|
for player in range(1, players + 1):
|
||||||
def set_player_attr(attr: str, val) -> None:
|
def set_player_attr(attr: str, val) -> None:
|
||||||
self.__dict__.setdefault(attr, {})[player] = val
|
self.__dict__.setdefault(attr, {})[player] = val
|
||||||
set_player_attr('plando_items', [])
|
set_player_attr('plando_item_blocks', [])
|
||||||
set_player_attr('plando_texts', {})
|
|
||||||
set_player_attr('plando_connections', [])
|
|
||||||
set_player_attr('game', "Archipelago")
|
set_player_attr('game', "Archipelago")
|
||||||
set_player_attr('completion_condition', lambda state: True)
|
set_player_attr('completion_condition', lambda state: True)
|
||||||
self.worlds = {}
|
self.worlds = {}
|
||||||
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
||||||
"world's random object instead (usually self.random)")
|
"world's random object instead (usually self.random)", True)
|
||||||
self.plando_options = PlandoOptions.none
|
self.plando_options = PlandoOptions.none
|
||||||
|
|
||||||
def get_all_ids(self) -> Tuple[int, ...]:
|
def get_all_ids(self) -> Tuple[int, ...]:
|
||||||
@@ -215,17 +222,8 @@ class MultiWorld():
|
|||||||
self.seed_name = name if name else str(self.seed)
|
self.seed_name = name if name else str(self.seed)
|
||||||
|
|
||||||
def set_options(self, args: Namespace) -> None:
|
def set_options(self, args: Namespace) -> None:
|
||||||
# TODO - remove this section once all worlds use options dataclasses
|
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
|
|
||||||
all_keys: Set[str] = {key for player in self.player_ids for key in
|
|
||||||
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
|
|
||||||
for option_key in all_keys:
|
|
||||||
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
|
|
||||||
f"Please use `self.options.{option_key}` instead.")
|
|
||||||
option.update(getattr(args, option_key, {}))
|
|
||||||
setattr(self, option_key, option)
|
|
||||||
|
|
||||||
for player in self.player_ids:
|
for player in self.player_ids:
|
||||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||||
self.worlds[player] = world_type(self, player)
|
self.worlds[player] = world_type(self, player)
|
||||||
@@ -263,6 +261,7 @@ class MultiWorld():
|
|||||||
"local_items": set(item_link.get("local_items", [])),
|
"local_items": set(item_link.get("local_items", [])),
|
||||||
"non_local_items": set(item_link.get("non_local_items", [])),
|
"non_local_items": set(item_link.get("non_local_items", [])),
|
||||||
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
||||||
|
"skip_if_solo": item_link.get("skip_if_solo", False),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _name, item_link in item_links.items():
|
for _name, item_link in item_links.items():
|
||||||
@@ -286,6 +285,8 @@ class MultiWorld():
|
|||||||
|
|
||||||
for group_name, item_link in item_links.items():
|
for group_name, item_link in item_links.items():
|
||||||
game = item_link["game"]
|
game = item_link["game"]
|
||||||
|
if item_link["skip_if_solo"] and len(item_link["players"]) == 1:
|
||||||
|
continue
|
||||||
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
|
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
|
||||||
|
|
||||||
group["item_pool"] = item_link["item_pool"]
|
group["item_pool"] = item_link["item_pool"]
|
||||||
@@ -426,23 +427,39 @@ class MultiWorld():
|
|||||||
def get_location(self, location_name: str, player: int) -> Location:
|
def get_location(self, location_name: str, player: int) -> Location:
|
||||||
return self.regions.location_cache[player][location_name]
|
return self.regions.location_cache[player][location_name]
|
||||||
|
|
||||||
def get_all_state(self, use_cache: bool) -> CollectionState:
|
def get_all_state(self, use_cache: bool | None = None, allow_partial_entrances: bool = False,
|
||||||
cached = getattr(self, "_all_state", None)
|
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
|
||||||
if use_cache and cached:
|
"""
|
||||||
return cached.copy()
|
Creates a new CollectionState, and collects all precollected items, all items in the multiworld itempool, those
|
||||||
|
specified in each worlds' `get_pre_fill_items()`, and then sweeps the multiworld collecting any other items
|
||||||
|
it is able to reach, building as complete of a completed game state as possible.
|
||||||
|
|
||||||
ret = CollectionState(self)
|
:param use_cache: Deprecated and unused.
|
||||||
|
:param allow_partial_entrances: Whether the CollectionState should allow for disconnected entrances while
|
||||||
|
sweeping, such as before entrance randomization is complete.
|
||||||
|
:param collect_pre_fill_items: Whether the items in each worlds' `get_pre_fill_items()` should be added to this
|
||||||
|
state.
|
||||||
|
:param perform_sweep: Whether this state should perform a sweep for reachable locations, collecting any placed
|
||||||
|
items it can.
|
||||||
|
|
||||||
|
:return: The completed CollectionState.
|
||||||
|
"""
|
||||||
|
if __debug__ and use_cache is not None:
|
||||||
|
# TODO swap to Utils.deprecate when we want this to crash on source and warn on frozen
|
||||||
|
warnings.warn("multiworld.get_all_state no longer caches all_state and this argument will be removed.",
|
||||||
|
DeprecationWarning)
|
||||||
|
ret = CollectionState(self, allow_partial_entrances)
|
||||||
|
|
||||||
for item in self.itempool:
|
for item in self.itempool:
|
||||||
self.worlds[item.player].collect(ret, item)
|
self.worlds[item.player].collect(ret, item)
|
||||||
for player in self.player_ids:
|
if collect_pre_fill_items:
|
||||||
subworld = self.worlds[player]
|
for player in self.player_ids:
|
||||||
for item in subworld.get_pre_fill_items():
|
subworld = self.worlds[player]
|
||||||
subworld.collect(ret, item)
|
for item in subworld.get_pre_fill_items():
|
||||||
ret.sweep_for_advancements()
|
subworld.collect(ret, item)
|
||||||
|
if perform_sweep:
|
||||||
|
ret.sweep_for_advancements()
|
||||||
|
|
||||||
if use_cache:
|
|
||||||
self._all_state = ret
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def get_items(self) -> List[Item]:
|
def get_items(self) -> List[Item]:
|
||||||
@@ -544,7 +561,9 @@ class MultiWorld():
|
|||||||
else:
|
else:
|
||||||
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
|
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
|
||||||
|
|
||||||
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
|
def can_beat_game(self,
|
||||||
|
starting_state: Optional[CollectionState] = None,
|
||||||
|
locations: Optional[Iterable[Location]] = None) -> bool:
|
||||||
if starting_state:
|
if starting_state:
|
||||||
if self.has_beaten_game(starting_state):
|
if self.has_beaten_game(starting_state):
|
||||||
return True
|
return True
|
||||||
@@ -553,25 +572,10 @@ class MultiWorld():
|
|||||||
state = CollectionState(self)
|
state = CollectionState(self)
|
||||||
if self.has_beaten_game(state):
|
if self.has_beaten_game(state):
|
||||||
return True
|
return True
|
||||||
prog_locations = {location for location in self.get_locations() if location.item
|
|
||||||
and location.item.advancement and location not in state.locations_checked}
|
|
||||||
|
|
||||||
while prog_locations:
|
|
||||||
sphere: Set[Location] = set()
|
|
||||||
# build up spheres of collection radius.
|
|
||||||
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
|
||||||
for location in prog_locations:
|
|
||||||
if location.can_reach(state):
|
|
||||||
sphere.add(location)
|
|
||||||
|
|
||||||
if not sphere:
|
|
||||||
# ran out of places and did not finish yet, quit
|
|
||||||
return False
|
|
||||||
|
|
||||||
for location in sphere:
|
|
||||||
state.collect(location.item, True, location)
|
|
||||||
prog_locations -= sphere
|
|
||||||
|
|
||||||
|
for _ in state.sweep_for_advancements(locations,
|
||||||
|
yield_each_sweep=True,
|
||||||
|
checked_locations=state.locations_checked):
|
||||||
if self.has_beaten_game(state):
|
if self.has_beaten_game(state):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -604,6 +608,49 @@ class MultiWorld():
|
|||||||
state.collect(location.item, True, location)
|
state.collect(location.item, True, location)
|
||||||
locations -= sphere
|
locations -= sphere
|
||||||
|
|
||||||
|
def get_sendable_spheres(self) -> Iterator[Set[Location]]:
|
||||||
|
"""
|
||||||
|
yields a set of multiserver sendable locations (location.item.code: int) for each logical sphere
|
||||||
|
|
||||||
|
If there are unreachable locations, the last sphere of reachable locations is followed by an empty set,
|
||||||
|
and then a set of all of the unreachable locations.
|
||||||
|
"""
|
||||||
|
state = CollectionState(self)
|
||||||
|
locations: Set[Location] = set()
|
||||||
|
events: Set[Location] = set()
|
||||||
|
for location in self.get_filled_locations():
|
||||||
|
if type(location.item.code) is int and type(location.address) is int:
|
||||||
|
locations.add(location)
|
||||||
|
else:
|
||||||
|
events.add(location)
|
||||||
|
|
||||||
|
while locations:
|
||||||
|
sphere: Set[Location] = set()
|
||||||
|
|
||||||
|
# cull events out
|
||||||
|
done_events: Set[Union[Location, None]] = {None}
|
||||||
|
while done_events:
|
||||||
|
done_events = set()
|
||||||
|
for event in events:
|
||||||
|
if event.can_reach(state):
|
||||||
|
state.collect(event.item, True, event)
|
||||||
|
done_events.add(event)
|
||||||
|
events -= done_events
|
||||||
|
|
||||||
|
for location in locations:
|
||||||
|
if location.can_reach(state):
|
||||||
|
sphere.add(location)
|
||||||
|
|
||||||
|
yield sphere
|
||||||
|
if not sphere:
|
||||||
|
if locations:
|
||||||
|
yield locations # unreachable locations
|
||||||
|
break
|
||||||
|
|
||||||
|
for location in sphere:
|
||||||
|
state.collect(location.item, True, location)
|
||||||
|
locations -= sphere
|
||||||
|
|
||||||
def fulfills_accessibility(self, state: Optional[CollectionState] = None):
|
def fulfills_accessibility(self, state: Optional[CollectionState] = None):
|
||||||
"""Check if accessibility rules are fulfilled with current or supplied state."""
|
"""Check if accessibility rules are fulfilled with current or supplied state."""
|
||||||
if not state:
|
if not state:
|
||||||
@@ -644,6 +691,12 @@ class MultiWorld():
|
|||||||
sphere.append(locations.pop(n))
|
sphere.append(locations.pop(n))
|
||||||
|
|
||||||
if not sphere:
|
if not sphere:
|
||||||
|
if __debug__:
|
||||||
|
from Fill import FillError
|
||||||
|
raise FillError(
|
||||||
|
f"Could not access required locations for accessibility check. Missing: {locations}",
|
||||||
|
multiworld=self,
|
||||||
|
)
|
||||||
# ran out of places and did not finish yet, quit
|
# ran out of places and did not finish yet, quit
|
||||||
logging.warning(f"Could not access required locations for accessibility check."
|
logging.warning(f"Could not access required locations for accessibility check."
|
||||||
f" Missing: {locations}")
|
f" Missing: {locations}")
|
||||||
@@ -674,10 +727,12 @@ class CollectionState():
|
|||||||
path: Dict[Union[Region, Entrance], PathValue]
|
path: Dict[Union[Region, Entrance], PathValue]
|
||||||
locations_checked: Set[Location]
|
locations_checked: Set[Location]
|
||||||
stale: Dict[int, bool]
|
stale: Dict[int, bool]
|
||||||
|
allow_partial_entrances: bool
|
||||||
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
|
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
|
||||||
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
||||||
|
|
||||||
def __init__(self, parent: MultiWorld):
|
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
|
||||||
|
assert parent.worlds, "CollectionState created without worlds initialized in parent"
|
||||||
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
||||||
self.multiworld = parent
|
self.multiworld = parent
|
||||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||||
@@ -686,6 +741,7 @@ class CollectionState():
|
|||||||
self.path = {}
|
self.path = {}
|
||||||
self.locations_checked = set()
|
self.locations_checked = set()
|
||||||
self.stale = {player: True for player in parent.get_all_ids()}
|
self.stale = {player: True for player in parent.get_all_ids()}
|
||||||
|
self.allow_partial_entrances = allow_partial_entrances
|
||||||
for function in self.additional_init_functions:
|
for function in self.additional_init_functions:
|
||||||
function(self, parent)
|
function(self, parent)
|
||||||
for items in parent.precollected_items.values():
|
for items in parent.precollected_items.values():
|
||||||
@@ -720,6 +776,8 @@ class CollectionState():
|
|||||||
if new_region in reachable_regions:
|
if new_region in reachable_regions:
|
||||||
blocked_connections.remove(connection)
|
blocked_connections.remove(connection)
|
||||||
elif connection.can_reach(self):
|
elif connection.can_reach(self):
|
||||||
|
if self.allow_partial_entrances and not new_region:
|
||||||
|
continue
|
||||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
||||||
reachable_regions.add(new_region)
|
reachable_regions.add(new_region)
|
||||||
blocked_connections.remove(connection)
|
blocked_connections.remove(connection)
|
||||||
@@ -745,7 +803,9 @@ class CollectionState():
|
|||||||
if new_region in reachable_regions:
|
if new_region in reachable_regions:
|
||||||
blocked_connections.remove(connection)
|
blocked_connections.remove(connection)
|
||||||
elif connection.can_reach(self):
|
elif connection.can_reach(self):
|
||||||
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
|
if self.allow_partial_entrances and not new_region:
|
||||||
|
continue
|
||||||
|
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
|
||||||
reachable_regions.add(new_region)
|
reachable_regions.add(new_region)
|
||||||
blocked_connections.remove(connection)
|
blocked_connections.remove(connection)
|
||||||
blocked_connections.update(new_region.exits)
|
blocked_connections.update(new_region.exits)
|
||||||
@@ -765,6 +825,7 @@ class CollectionState():
|
|||||||
ret.advancements = self.advancements.copy()
|
ret.advancements = self.advancements.copy()
|
||||||
ret.path = self.path.copy()
|
ret.path = self.path.copy()
|
||||||
ret.locations_checked = self.locations_checked.copy()
|
ret.locations_checked = self.locations_checked.copy()
|
||||||
|
ret.allow_partial_entrances = self.allow_partial_entrances
|
||||||
for function in self.additional_copy_functions:
|
for function in self.additional_copy_functions:
|
||||||
ret = function(self, ret)
|
ret = function(self, ret)
|
||||||
return ret
|
return ret
|
||||||
@@ -799,40 +860,172 @@ class CollectionState():
|
|||||||
"Please switch over to sweep_for_advancements.")
|
"Please switch over to sweep_for_advancements.")
|
||||||
return self.sweep_for_advancements(locations)
|
return self.sweep_for_advancements(locations)
|
||||||
|
|
||||||
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
|
def _sweep_for_advancements_impl(self, advancements_per_player: List[Tuple[int, List[Location]]],
|
||||||
if locations is None:
|
yield_each_sweep: bool) -> Iterator[None]:
|
||||||
locations = self.multiworld.get_filled_locations()
|
"""
|
||||||
reachable_advancements = True
|
The implementation for sweep_for_advancements is separated here because it returns a generator due to the use
|
||||||
# since the loop has a good chance to run more than once, only filter the advancements once
|
of a yield statement.
|
||||||
locations = {location for location in locations if location.advancement and location not in self.advancements}
|
"""
|
||||||
|
all_players = {player for player, _ in advancements_per_player}
|
||||||
|
players_to_check = all_players
|
||||||
|
# As an optimization, it is assumed that each player's world only logically depends on itself. However, worlds
|
||||||
|
# are allowed to logically depend on other worlds, so once there are no more players that should be checked
|
||||||
|
# under this assumption, an extra sweep iteration is performed that checks every player, to confirm that the
|
||||||
|
# sweep is finished.
|
||||||
|
checking_if_finished = False
|
||||||
|
while players_to_check:
|
||||||
|
next_advancements_per_player: List[Tuple[int, List[Location]]] = []
|
||||||
|
next_players_to_check = set()
|
||||||
|
|
||||||
while reachable_advancements:
|
for player, locations in advancements_per_player:
|
||||||
reachable_advancements = {location for location in locations if location.can_reach(self)}
|
if player not in players_to_check:
|
||||||
locations -= reachable_advancements
|
next_advancements_per_player.append((player, locations))
|
||||||
for advancement in reachable_advancements:
|
continue
|
||||||
self.advancements.add(advancement)
|
|
||||||
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
|
# Accessibility of each location is checked first because a player's region accessibility cache becomes
|
||||||
self.collect(advancement.item, True, advancement)
|
# stale whenever one of their own items is collected into the state.
|
||||||
|
reachable_locations: List[Location] = []
|
||||||
|
unreachable_locations: List[Location] = []
|
||||||
|
for location in locations:
|
||||||
|
if location.can_reach(self):
|
||||||
|
# Locations containing items that do not belong to `player` could be collected immediately
|
||||||
|
# because they won't stale `player`'s region accessibility cache, but, for simplicity, all the
|
||||||
|
# items at reachable locations are collected in a single loop.
|
||||||
|
reachable_locations.append(location)
|
||||||
|
else:
|
||||||
|
unreachable_locations.append(location)
|
||||||
|
if unreachable_locations:
|
||||||
|
next_advancements_per_player.append((player, unreachable_locations))
|
||||||
|
|
||||||
|
# A previous player's locations processed in the current `while players_to_check` iteration could have
|
||||||
|
# collected items belonging to `player`, but now that all of `player`'s reachable locations have been
|
||||||
|
# found, it can be assumed that `player` will not gain any more reachable locations until another one of
|
||||||
|
# their items is collected.
|
||||||
|
# It would be clearer to not add players to `next_players_to_check` in the first place if they have yet
|
||||||
|
# to be processed in the current `while players_to_check` iteration, but checking if a player should be
|
||||||
|
# added to `next_players_to_check` would need to be run once for every item that is collected, so it is
|
||||||
|
# more performant to instead discard `player` from `next_players_to_check` once their locations have
|
||||||
|
# been processed.
|
||||||
|
next_players_to_check.discard(player)
|
||||||
|
|
||||||
|
# Collect the items from the reachable locations.
|
||||||
|
for advancement in reachable_locations:
|
||||||
|
self.advancements.add(advancement)
|
||||||
|
item = advancement.item
|
||||||
|
assert isinstance(item, Item), "tried to collect advancement Location with no Item"
|
||||||
|
if self.collect(item, True, advancement):
|
||||||
|
# The player the item belongs to may be able to reach additional locations in the next sweep
|
||||||
|
# iteration.
|
||||||
|
next_players_to_check.add(item.player)
|
||||||
|
|
||||||
|
if not next_players_to_check:
|
||||||
|
if not checking_if_finished:
|
||||||
|
# It is assumed that each player's world only logically depends on itself, which may not be the
|
||||||
|
# case, so confirm that the sweep is finished by doing an extra iteration that checks every player.
|
||||||
|
checking_if_finished = True
|
||||||
|
next_players_to_check = all_players
|
||||||
|
else:
|
||||||
|
checking_if_finished = False
|
||||||
|
|
||||||
|
players_to_check = next_players_to_check
|
||||||
|
advancements_per_player = next_advancements_per_player
|
||||||
|
|
||||||
|
if yield_each_sweep:
|
||||||
|
yield
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, *,
|
||||||
|
yield_each_sweep: Literal[True],
|
||||||
|
checked_locations: Optional[Set[Location]] = None) -> Iterator[None]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None,
|
||||||
|
yield_each_sweep: Literal[False] = False,
|
||||||
|
checked_locations: Optional[Set[Location]] = None) -> None: ...
|
||||||
|
|
||||||
|
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, yield_each_sweep: bool = False,
|
||||||
|
checked_locations: Optional[Set[Location]] = None) -> Optional[Iterator[None]]:
|
||||||
|
"""
|
||||||
|
Sweep through the locations that contain uncollected advancement items, collecting the items into the state
|
||||||
|
until there are no more reachable locations that contain uncollected advancement items.
|
||||||
|
|
||||||
|
:param locations: The locations to sweep through, defaulting to all locations in the multiworld.
|
||||||
|
:param yield_each_sweep: When True, return a generator that yields at the end of each sweep iteration.
|
||||||
|
:param checked_locations: Optional override of locations to filter out from the locations argument, defaults to
|
||||||
|
self.advancements when None.
|
||||||
|
"""
|
||||||
|
if checked_locations is None:
|
||||||
|
checked_locations = self.advancements
|
||||||
|
|
||||||
|
# Since the sweep loop usually performs many iterations, the locations are filtered in advance.
|
||||||
|
# A list of tuples is used, instead of a dictionary, because it is faster to iterate.
|
||||||
|
advancements_per_player: List[Tuple[int, List[Location]]]
|
||||||
|
if locations is None:
|
||||||
|
# `location.advancement` can only be True for filled locations, so unfilled locations are filtered out.
|
||||||
|
advancements_per_player = []
|
||||||
|
for player, locations_dict in self.multiworld.regions.location_cache.items():
|
||||||
|
filtered_locations = [location for location in locations_dict.values()
|
||||||
|
if location.advancement and location not in checked_locations]
|
||||||
|
if filtered_locations:
|
||||||
|
advancements_per_player.append((player, filtered_locations))
|
||||||
|
else:
|
||||||
|
# Filter and separate the locations into a list for each player.
|
||||||
|
advancements_per_player_dict: Dict[int, List[Location]] = defaultdict(list)
|
||||||
|
for location in locations:
|
||||||
|
if location.advancement and location not in checked_locations:
|
||||||
|
advancements_per_player_dict[location.player].append(location)
|
||||||
|
# Convert to a list of tuples.
|
||||||
|
advancements_per_player = list(advancements_per_player_dict.items())
|
||||||
|
del advancements_per_player_dict
|
||||||
|
|
||||||
|
if yield_each_sweep:
|
||||||
|
# Return a generator that will yield at the end of each sweep iteration.
|
||||||
|
return self._sweep_for_advancements_impl(advancements_per_player, True)
|
||||||
|
else:
|
||||||
|
# Create the generator, but tell it not to yield anything, so it will run to completion in zero iterations
|
||||||
|
# once started, then start and exhaust the generator by attempting to iterate it.
|
||||||
|
for _ in self._sweep_for_advancements_impl(advancements_per_player, False):
|
||||||
|
assert False, "Generator yielded when it should have run to completion without yielding"
|
||||||
|
return None
|
||||||
|
|
||||||
# item name related
|
# item name related
|
||||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||||
return self.prog_items[player][item] >= count
|
return self.prog_items[player][item] >= count
|
||||||
|
|
||||||
|
# for loops are specifically used in all/any/count methods, instead of all()/any()/sum(), to avoid the overhead of
|
||||||
|
# creating and iterating generator instances. In `return all(player_prog_items[item] for item in items)`, the
|
||||||
|
# argument to all() would be a new generator instance, for example.
|
||||||
def has_all(self, items: Iterable[str], player: int) -> bool:
|
def has_all(self, items: Iterable[str], player: int) -> bool:
|
||||||
"""Returns True if each item name of items is in state at least once."""
|
"""Returns True if each item name of items is in state at least once."""
|
||||||
return all(self.prog_items[player][item] for item in items)
|
player_prog_items = self.prog_items[player]
|
||||||
|
for item in items:
|
||||||
|
if not player_prog_items[item]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def has_any(self, items: Iterable[str], player: int) -> bool:
|
def has_any(self, items: Iterable[str], player: int) -> bool:
|
||||||
"""Returns True if at least one item name of items is in state at least once."""
|
"""Returns True if at least one item name of items is in state at least once."""
|
||||||
return any(self.prog_items[player][item] for item in items)
|
player_prog_items = self.prog_items[player]
|
||||||
|
for item in items:
|
||||||
|
if player_prog_items[item]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
|
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
|
||||||
"""Returns True if each item name is in the state at least as many times as specified."""
|
"""Returns True if each item name is in the state at least as many times as specified."""
|
||||||
return all(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
player_prog_items = self.prog_items[player]
|
||||||
|
for item, count in item_counts.items():
|
||||||
|
if player_prog_items[item] < count:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
|
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
|
||||||
"""Returns True if at least one item name is in the state at least as many times as specified."""
|
"""Returns True if at least one item name is in the state at least as many times as specified."""
|
||||||
return any(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
player_prog_items = self.prog_items[player]
|
||||||
|
for item, count in item_counts.items():
|
||||||
|
if player_prog_items[item] >= count:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def count(self, item: str, player: int) -> int:
|
def count(self, item: str, player: int) -> int:
|
||||||
return self.prog_items[player][item]
|
return self.prog_items[player][item]
|
||||||
@@ -860,11 +1053,20 @@ class CollectionState():
|
|||||||
|
|
||||||
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
||||||
"""Returns the cumulative count of items from a list present in state."""
|
"""Returns the cumulative count of items from a list present in state."""
|
||||||
return sum(self.prog_items[player][item_name] for item_name in items)
|
player_prog_items = self.prog_items[player]
|
||||||
|
total = 0
|
||||||
|
for item_name in items:
|
||||||
|
total += player_prog_items[item_name]
|
||||||
|
return total
|
||||||
|
|
||||||
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
||||||
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
||||||
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
|
player_prog_items = self.prog_items[player]
|
||||||
|
total = 0
|
||||||
|
for item_name in items:
|
||||||
|
if player_prog_items[item_name] > 0:
|
||||||
|
total += 1
|
||||||
|
return total
|
||||||
|
|
||||||
# item name group related
|
# item name group related
|
||||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||||
@@ -920,6 +1122,17 @@ class CollectionState():
|
|||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
def add_item(self, item: str, player: int, count: int = 1) -> None:
|
||||||
|
"""
|
||||||
|
Adds the item to state.
|
||||||
|
|
||||||
|
:param item: The item to be added.
|
||||||
|
:param player: The player the item is for.
|
||||||
|
:param count: How many of the item to add.
|
||||||
|
"""
|
||||||
|
assert count > 0
|
||||||
|
self.prog_items[player][item] += count
|
||||||
|
|
||||||
def remove(self, item: Item):
|
def remove(self, item: Item):
|
||||||
changed = self.multiworld.worlds[item.player].remove(self, item)
|
changed = self.multiworld.worlds[item.player].remove(self, item)
|
||||||
if changed:
|
if changed:
|
||||||
@@ -928,6 +1141,38 @@ class CollectionState():
|
|||||||
self.blocked_connections[item.player] = set()
|
self.blocked_connections[item.player] = set()
|
||||||
self.stale[item.player] = True
|
self.stale[item.player] = True
|
||||||
|
|
||||||
|
def remove_item(self, item: str, player: int, count: int = 1) -> None:
|
||||||
|
"""
|
||||||
|
Removes the item from state.
|
||||||
|
|
||||||
|
:param item: The item to be removed.
|
||||||
|
:param player: The player the item is for.
|
||||||
|
:param count: How many of the item to remove.
|
||||||
|
"""
|
||||||
|
assert count > 0
|
||||||
|
self.prog_items[player][item] -= count
|
||||||
|
if self.prog_items[player][item] < 1:
|
||||||
|
del (self.prog_items[player][item])
|
||||||
|
|
||||||
|
def set_item(self, item: str, player: int, count: int) -> None:
|
||||||
|
"""
|
||||||
|
Sets the item in state equal to the provided count.
|
||||||
|
|
||||||
|
:param item: The item to modify.
|
||||||
|
:param player: The player the item is for.
|
||||||
|
:param count: How many of the item to now have.
|
||||||
|
"""
|
||||||
|
assert count >= 0
|
||||||
|
if count == 0:
|
||||||
|
del (self.prog_items[player][item])
|
||||||
|
else:
|
||||||
|
self.prog_items[player][item] = count
|
||||||
|
|
||||||
|
|
||||||
|
class EntranceType(IntEnum):
|
||||||
|
ONE_WAY = 1
|
||||||
|
TWO_WAY = 2
|
||||||
|
|
||||||
|
|
||||||
class Entrance:
|
class Entrance:
|
||||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||||
@@ -936,30 +1181,56 @@ class Entrance:
|
|||||||
name: str
|
name: str
|
||||||
parent_region: Optional[Region]
|
parent_region: Optional[Region]
|
||||||
connected_region: Optional[Region] = None
|
connected_region: Optional[Region] = None
|
||||||
# LttP specific, TODO: should make a LttPEntrance
|
randomization_group: int
|
||||||
addresses = None
|
randomization_type: EntranceType
|
||||||
target = None
|
|
||||||
|
|
||||||
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None:
|
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
|
||||||
|
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.parent_region = parent
|
self.parent_region = parent
|
||||||
self.player = player
|
self.player = player
|
||||||
|
self.randomization_group = randomization_group
|
||||||
|
self.randomization_type = randomization_type
|
||||||
|
|
||||||
def can_reach(self, state: CollectionState) -> bool:
|
def can_reach(self, state: CollectionState) -> bool:
|
||||||
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
|
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
|
||||||
if self.parent_region.can_reach(state) and self.access_rule(state):
|
if self.parent_region.can_reach(state) and self.access_rule(state):
|
||||||
if not self.hide_path and not self in state.path:
|
if not self.hide_path and self not in state.path:
|
||||||
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
|
def connect(self, region: Region) -> None:
|
||||||
self.connected_region = region
|
self.connected_region = region
|
||||||
self.target = target
|
|
||||||
self.addresses = addresses
|
|
||||||
region.entrances.append(self)
|
region.entrances.append(self)
|
||||||
|
|
||||||
|
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
|
||||||
|
"""
|
||||||
|
Determines whether this is a valid source transition, that is, whether the entrance
|
||||||
|
randomizer is allowed to pair it to place any other regions. By default, this is the
|
||||||
|
same as a reachability check, but can be modified by Entrance implementations to add
|
||||||
|
other restrictions based on the placement state.
|
||||||
|
|
||||||
|
:param er_state: The current (partial) state of the ongoing entrance randomization
|
||||||
|
"""
|
||||||
|
return self.can_reach(er_state.collection_state)
|
||||||
|
|
||||||
|
def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool:
|
||||||
|
"""
|
||||||
|
Determines whether a given Entrance is a valid target transition, that is, whether
|
||||||
|
the entrance randomizer is allowed to pair this Entrance to that Entrance. By default,
|
||||||
|
only allows connection between entrances of the same type (one ways only go to one ways,
|
||||||
|
two ways always go to two ways) and prevents connecting an exit to itself in coupled mode.
|
||||||
|
|
||||||
|
:param other: The proposed Entrance to connect to
|
||||||
|
:param dead_end: Whether the other entrance considered a dead end by Entrance randomization
|
||||||
|
:param er_state: The current (partial) state of the ongoing entrance randomization
|
||||||
|
"""
|
||||||
|
# the implementation of coupled causes issues for self-loops since the reverse entrance will be the
|
||||||
|
# same as the forward entrance. In uncoupled they are ok.
|
||||||
|
return self.randomization_type == other.randomization_type and (not er_state.coupled or self.name != other.name)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
multiworld = self.parent_region.multiworld if self.parent_region else None
|
multiworld = self.parent_region.multiworld if self.parent_region else None
|
||||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||||
@@ -983,13 +1254,16 @@ class Region:
|
|||||||
self.region_manager = region_manager
|
self.region_manager = region_manager
|
||||||
|
|
||||||
def __getitem__(self, index: int) -> Location:
|
def __getitem__(self, index: int) -> Location:
|
||||||
return self._list.__getitem__(index)
|
return self._list[index]
|
||||||
|
|
||||||
def __setitem__(self, index: int, value: Location) -> None:
|
def __setitem__(self, index: int, value: Location) -> None:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self._list.__len__()
|
return len(self._list)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self._list)
|
||||||
|
|
||||||
# This seems to not be needed, but that's a bit suspicious.
|
# This seems to not be needed, but that's a bit suspicious.
|
||||||
# def __del__(self):
|
# def __del__(self):
|
||||||
@@ -1000,8 +1274,8 @@ class Region:
|
|||||||
|
|
||||||
class LocationRegister(Register):
|
class LocationRegister(Register):
|
||||||
def __delitem__(self, index: int) -> None:
|
def __delitem__(self, index: int) -> None:
|
||||||
location: Location = self._list.__getitem__(index)
|
location: Location = self._list[index]
|
||||||
self._list.__delitem__(index)
|
del self._list[index]
|
||||||
del(self.region_manager.location_cache[location.player][location.name])
|
del(self.region_manager.location_cache[location.player][location.name])
|
||||||
|
|
||||||
def insert(self, index: int, value: Location) -> None:
|
def insert(self, index: int, value: Location) -> None:
|
||||||
@@ -1012,8 +1286,8 @@ class Region:
|
|||||||
|
|
||||||
class EntranceRegister(Register):
|
class EntranceRegister(Register):
|
||||||
def __delitem__(self, index: int) -> None:
|
def __delitem__(self, index: int) -> None:
|
||||||
entrance: Entrance = self._list.__getitem__(index)
|
entrance: Entrance = self._list[index]
|
||||||
self._list.__delitem__(index)
|
del self._list[index]
|
||||||
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
|
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
|
||||||
|
|
||||||
def insert(self, index: int, value: Entrance) -> None:
|
def insert(self, index: int, value: Entrance) -> None:
|
||||||
@@ -1072,8 +1346,7 @@ class Region:
|
|||||||
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
|
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
|
||||||
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
||||||
|
|
||||||
def add_locations(self, locations: Dict[str, Optional[int]],
|
def add_locations(self, locations: Mapping[str, int | None], location_type: type[Location] | None = None) -> None:
|
||||||
location_type: Optional[type[Location]] = None) -> None:
|
|
||||||
"""
|
"""
|
||||||
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
|
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
|
||||||
location names to address.
|
location names to address.
|
||||||
@@ -1085,6 +1358,48 @@ class Region:
|
|||||||
for location, address in locations.items():
|
for location, address in locations.items():
|
||||||
self.locations.append(location_type(self.player, location, address, self))
|
self.locations.append(location_type(self.player, location, address, self))
|
||||||
|
|
||||||
|
def add_event(
|
||||||
|
self,
|
||||||
|
location_name: str,
|
||||||
|
item_name: str | None = None,
|
||||||
|
rule: Callable[[CollectionState], bool] | None = None,
|
||||||
|
location_type: type[Location] | None = None,
|
||||||
|
item_type: type[Item] | None = None,
|
||||||
|
show_in_spoiler: bool = True,
|
||||||
|
) -> Item:
|
||||||
|
"""
|
||||||
|
Adds an event location/item pair to the region.
|
||||||
|
|
||||||
|
:param location_name: Name for the event location.
|
||||||
|
:param item_name: Name for the event item. If not provided, defaults to location_name.
|
||||||
|
:param rule: Callable to determine access for this event location within its region.
|
||||||
|
:param location_type: Location class to create the event location with. Defaults to BaseClasses.Location.
|
||||||
|
:param item_type: Item class to create the event item with. Defaults to BaseClasses.Item.
|
||||||
|
:param show_in_spoiler: Will be passed along to the created event Location's show_in_spoiler attribute.
|
||||||
|
:return: The created Event Item
|
||||||
|
"""
|
||||||
|
if location_type is None:
|
||||||
|
location_type = Location
|
||||||
|
|
||||||
|
if item_name is None:
|
||||||
|
item_name = location_name
|
||||||
|
|
||||||
|
if item_type is None:
|
||||||
|
item_type = Item
|
||||||
|
|
||||||
|
event_location = location_type(self.player, location_name, None, self)
|
||||||
|
event_location.show_in_spoiler = show_in_spoiler
|
||||||
|
if rule is not None:
|
||||||
|
event_location.access_rule = rule
|
||||||
|
|
||||||
|
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
|
||||||
|
|
||||||
|
event_location.place_locked_item(event_item)
|
||||||
|
|
||||||
|
self.locations.append(event_location)
|
||||||
|
|
||||||
|
return event_item
|
||||||
|
|
||||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
||||||
"""
|
"""
|
||||||
@@ -1109,21 +1424,35 @@ class Region:
|
|||||||
self.exits.append(exit_)
|
self.exits.append(exit_)
|
||||||
return exit_
|
return exit_
|
||||||
|
|
||||||
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
|
def create_er_target(self, name: str) -> Entrance:
|
||||||
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None:
|
"""
|
||||||
|
Creates and returns an Entrance object as an entrance to this region
|
||||||
|
|
||||||
|
:param name: name of the Entrance being created
|
||||||
|
"""
|
||||||
|
entrance = self.entrance_type(self.player, name)
|
||||||
|
entrance.connect(self)
|
||||||
|
return entrance
|
||||||
|
|
||||||
|
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
|
||||||
|
rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]:
|
||||||
"""
|
"""
|
||||||
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
||||||
|
|
||||||
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
|
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
|
||||||
created entrances will be named "self.name -> connecting_region"
|
created entrances will be named "self.name -> connecting_region"
|
||||||
:param rules: rules for the exits from this region. format is {"connecting_region", rule}
|
:param rules: rules for the exits from this region. format is {"connecting_region": rule}
|
||||||
"""
|
"""
|
||||||
if not isinstance(exits, Dict):
|
if not isinstance(exits, Mapping):
|
||||||
exits = dict.fromkeys(exits)
|
exits = dict.fromkeys(exits)
|
||||||
for connecting_region, name in exits.items():
|
return [
|
||||||
self.connect(self.multiworld.get_region(connecting_region, self.player),
|
self.connect(
|
||||||
name,
|
self.multiworld.get_region(connecting_region, self.player),
|
||||||
rules[connecting_region] if rules and connecting_region in rules else None)
|
name,
|
||||||
|
rules[connecting_region] if rules and connecting_region in rules else None,
|
||||||
|
)
|
||||||
|
for connecting_region, name in exits.items()
|
||||||
|
]
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||||
@@ -1181,9 +1510,6 @@ class Location:
|
|||||||
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
||||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
def __hash__(self):
|
|
||||||
return hash((self.name, self.player))
|
|
||||||
|
|
||||||
def __lt__(self, other: Location):
|
def __lt__(self, other: Location):
|
||||||
return (self.player, self.name) < (other.player, other.name)
|
return (self.player, self.name) < (other.player, other.name)
|
||||||
|
|
||||||
@@ -1207,18 +1533,47 @@ class Location:
|
|||||||
|
|
||||||
|
|
||||||
class ItemClassification(IntFlag):
|
class ItemClassification(IntFlag):
|
||||||
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
|
filler = 0b00000
|
||||||
progression = 0b0001 # Item that is logically relevant
|
""" aka trash, as in filler items like ammo, currency etc """
|
||||||
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
|
|
||||||
trap = 0b0100 # detrimental item
|
progression = 0b00001
|
||||||
skip_balancing = 0b1000 # should technically never occur on its own
|
""" Item that is logically relevant.
|
||||||
# Item that is logically relevant, but progression balancing should not touch.
|
Protects this item from being placed on excluded or unreachable locations. """
|
||||||
# Typically currency or other counted items.
|
|
||||||
progression_skip_balancing = 0b1001 # only progression gets balanced
|
useful = 0b00010
|
||||||
|
""" Item that is especially useful.
|
||||||
|
Protects this item from being placed on excluded or unreachable locations.
|
||||||
|
When combined with another flag like "progression", it means "an especially useful progression item". """
|
||||||
|
|
||||||
|
trap = 0b00100
|
||||||
|
""" Item that is detrimental in some way. """
|
||||||
|
|
||||||
|
skip_balancing = 0b01000
|
||||||
|
""" should technically never occur on its own
|
||||||
|
Item that is logically relevant, but progression balancing should not touch.
|
||||||
|
|
||||||
|
Possible reasons for why an item should not be pulled ahead by progression balancing:
|
||||||
|
1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.)
|
||||||
|
2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """
|
||||||
|
|
||||||
|
deprioritized = 0b10000
|
||||||
|
""" Should technically never occur on its own.
|
||||||
|
Will not be considered for priority locations,
|
||||||
|
unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
|
||||||
|
|
||||||
|
Should be used for items that would feel bad for the player to find on a priority location.
|
||||||
|
Usually, these are items that are plentiful or insignificant. """
|
||||||
|
|
||||||
|
progression_deprioritized_skip_balancing = 0b11001
|
||||||
|
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression",
|
||||||
|
these items often want both flags. """
|
||||||
|
|
||||||
|
progression_skip_balancing = 0b01001 # only progression gets balanced
|
||||||
|
progression_deprioritized = 0b10001 # only progression can be placed during priority fill
|
||||||
|
|
||||||
def as_flag(self) -> int:
|
def as_flag(self) -> int:
|
||||||
"""As Network API flag int."""
|
"""As Network API flag int."""
|
||||||
return int(self & 0b0111)
|
return int(self & 0b00111)
|
||||||
|
|
||||||
|
|
||||||
class Item:
|
class Item:
|
||||||
@@ -1262,6 +1617,14 @@ class Item:
|
|||||||
def trap(self) -> bool:
|
def trap(self) -> bool:
|
||||||
return ItemClassification.trap in self.classification
|
return ItemClassification.trap in self.classification
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deprioritized(self) -> bool:
|
||||||
|
return ItemClassification.deprioritized in self.classification
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filler(self) -> bool:
|
||||||
|
return not (self.advancement or self.useful or self.trap)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def excludable(self) -> bool:
|
def excludable(self) -> bool:
|
||||||
return not (self.advancement or self.useful)
|
return not (self.advancement or self.useful)
|
||||||
@@ -1270,6 +1633,10 @@ class Item:
|
|||||||
def flags(self) -> int:
|
def flags(self) -> int:
|
||||||
return self.classification.as_flag()
|
return self.classification.as_flag()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_event(self) -> bool:
|
||||||
|
return self.code is None
|
||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
def __eq__(self, other: object) -> bool:
|
||||||
if not isinstance(other, Item):
|
if not isinstance(other, Item):
|
||||||
return NotImplemented
|
return NotImplemented
|
||||||
@@ -1354,44 +1721,50 @@ class Spoiler:
|
|||||||
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
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
|
location.item.name, location.item.player, location.name, location.player) for location in
|
||||||
sphere_candidates])
|
sphere_candidates])
|
||||||
if any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]):
|
if not multiworld.has_beaten_game(state):
|
||||||
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
raise RuntimeError("During playthrough generation, the game was determined to be unbeatable. "
|
||||||
f'Something went terribly wrong here.')
|
"Something went terribly wrong here. "
|
||||||
|
f"Unreachable progression items: {sphere_candidates}")
|
||||||
else:
|
else:
|
||||||
self.unreachables = sphere_candidates
|
self.unreachables = sphere_candidates
|
||||||
break
|
break
|
||||||
|
|
||||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
# 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
|
# reducing each range of influence to the bare minimum required inside it
|
||||||
restore_later: Dict[Location, Item] = {}
|
required_locations = {location for sphere in collection_spheres for location in sphere}
|
||||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||||
to_delete: Set[Location] = set()
|
to_delete: Set[Location] = set()
|
||||||
for location in sphere:
|
for location in sphere:
|
||||||
# we remove the item at location and check if game is still beatable
|
# we remove the location from required_locations to sweep from, and check if the game is still beatable
|
||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||||
location.item.player)
|
location.item.player)
|
||||||
old_item = location.item
|
required_locations.remove(location)
|
||||||
location.item = None
|
if multiworld.can_beat_game(state_cache[num], required_locations):
|
||||||
if multiworld.can_beat_game(state_cache[num]):
|
|
||||||
to_delete.add(location)
|
to_delete.add(location)
|
||||||
restore_later[location] = old_item
|
|
||||||
else:
|
else:
|
||||||
# still required, got to keep it around
|
# still required, got to keep it around
|
||||||
location.item = old_item
|
required_locations.add(location)
|
||||||
|
|
||||||
# cull entries in spheres for spoiler walkthrough at end
|
# cull entries in spheres for spoiler walkthrough at end
|
||||||
sphere -= to_delete
|
sphere -= to_delete
|
||||||
|
|
||||||
# second phase, sphere 0
|
# second phase, sphere 0
|
||||||
removed_precollected: List[Item] = []
|
removed_precollected: List[Item] = []
|
||||||
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
|
|
||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
for precollected_items in multiworld.precollected_items.values():
|
||||||
multiworld.precollected_items[item.player].remove(item)
|
# The list of items is mutated by removing one item at a time to determine if each item is required to beat
|
||||||
multiworld.state.remove(item)
|
# the game, and re-adding that item if it was required, so a copy needs to be made before iterating.
|
||||||
if not multiworld.can_beat_game():
|
for item in precollected_items.copy():
|
||||||
multiworld.push_precollected(item)
|
if not item.advancement:
|
||||||
else:
|
continue
|
||||||
removed_precollected.append(item)
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||||
|
precollected_items.remove(item)
|
||||||
|
multiworld.state.remove(item)
|
||||||
|
if not multiworld.can_beat_game(multiworld.state, required_locations):
|
||||||
|
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
|
||||||
|
multiworld.push_precollected(item)
|
||||||
|
else:
|
||||||
|
removed_precollected.append(item)
|
||||||
|
|
||||||
# we are now down to just the required progress items in collection_spheres. Unfortunately
|
# 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
|
# the previous pruning stage could potentially have made certain items dependant on others
|
||||||
@@ -1429,9 +1802,6 @@ class Spoiler:
|
|||||||
self.create_paths(state, collection_spheres)
|
self.create_paths(state, collection_spheres)
|
||||||
|
|
||||||
# repair the multiworld again
|
# repair the multiworld again
|
||||||
for location, item in restore_later.items():
|
|
||||||
location.item = item
|
|
||||||
|
|
||||||
for item in removed_precollected:
|
for item in removed_precollected:
|
||||||
multiworld.push_precollected(item)
|
multiworld.push_precollected(item)
|
||||||
|
|
||||||
@@ -1488,6 +1858,9 @@ class Spoiler:
|
|||||||
Utils.__version__, self.multiworld.seed))
|
Utils.__version__, self.multiworld.seed))
|
||||||
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
|
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
|
||||||
outfile.write('Players: %d\n' % self.multiworld.players)
|
outfile.write('Players: %d\n' % self.multiworld.players)
|
||||||
|
if self.multiworld.players > 1:
|
||||||
|
loc_count = len([loc for loc in self.multiworld.get_locations() if not loc.is_event])
|
||||||
|
outfile.write('Total Location Count: %d\n' % loc_count)
|
||||||
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
|
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
|
||||||
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
|
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
|
||||||
|
|
||||||
@@ -1496,6 +1869,9 @@ class Spoiler:
|
|||||||
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
|
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
|
||||||
outfile.write('Game: %s\n' % self.multiworld.game[player])
|
outfile.write('Game: %s\n' % self.multiworld.game[player])
|
||||||
|
|
||||||
|
loc_count = len([loc for loc in self.multiworld.get_locations(player) if not loc.is_event])
|
||||||
|
outfile.write('Location Count: %d\n' % loc_count)
|
||||||
|
|
||||||
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
|
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
|
||||||
write_option(f_option, option)
|
write_option(f_option, option)
|
||||||
|
|
||||||
@@ -1530,9 +1906,10 @@ class Spoiler:
|
|||||||
[f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else
|
[f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else
|
||||||
[f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
|
[f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
|
||||||
if self.unreachables:
|
if self.unreachables:
|
||||||
outfile.write('\n\nUnreachable Items:\n\n')
|
outfile.write('\n\nUnreachable Progression Items:\n\n')
|
||||||
outfile.write(
|
outfile.write(
|
||||||
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
|
'\n'.join(['%s: %s' % (unreachable.item, unreachable)
|
||||||
|
for unreachable in sorted(self.unreachables)]))
|
||||||
|
|
||||||
if self.paths:
|
if self.paths:
|
||||||
outfile.write('\n\nPaths:\n\n')
|
outfile.write('\n\nPaths:\n\n')
|
||||||
@@ -1559,7 +1936,7 @@ class Tutorial(NamedTuple):
|
|||||||
description: str
|
description: str
|
||||||
language: str
|
language: str
|
||||||
file_name: str
|
file_name: str
|
||||||
link: str
|
link: str # unused
|
||||||
authors: List[str]
|
authors: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
352
CommonClient.py
Normal file → Executable file
352
CommonClient.py
Normal file → Executable file
@@ -21,9 +21,9 @@ import Utils
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("TextClient", exception_logger="Client")
|
Utils.init_logging("TextClient", exception_logger="Client")
|
||||||
|
|
||||||
from MultiServer import CommandProcessor
|
from MultiServer import CommandProcessor, mark_raw
|
||||||
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
||||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, SlotType)
|
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
||||||
from Utils import Version, stream_input, async_start
|
from Utils import Version, stream_input, async_start
|
||||||
from worlds import network_data_package, AutoWorldRegister
|
from worlds import network_data_package, AutoWorldRegister
|
||||||
import os
|
import os
|
||||||
@@ -31,6 +31,7 @@ import ssl
|
|||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
import kvui
|
import kvui
|
||||||
|
import argparse
|
||||||
|
|
||||||
logger = logging.getLogger("Client")
|
logger = logging.getLogger("Client")
|
||||||
|
|
||||||
@@ -106,7 +107,9 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
return False
|
return False
|
||||||
count = 0
|
count = 0
|
||||||
checked_count = 0
|
checked_count = 0
|
||||||
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
|
||||||
|
lookup = self.ctx.location_names[self.ctx.game]
|
||||||
|
for location_id, location in lookup.items():
|
||||||
if filter_text and filter_text not in location:
|
if filter_text and filter_text not in location:
|
||||||
continue
|
continue
|
||||||
if location_id < 0:
|
if location_id < 0:
|
||||||
@@ -127,43 +130,87 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
self.output("No missing location checks found.")
|
self.output("No missing location checks found.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_items(self):
|
def output_datapackage_part(self, name: typing.Literal["Item Names", "Location Names"]) -> bool:
|
||||||
|
"""
|
||||||
|
Helper to digest a specific section of this game's datapackage.
|
||||||
|
|
||||||
|
:param name: Printed to the user as context for the part.
|
||||||
|
|
||||||
|
:return: Whether the process was successful.
|
||||||
|
"""
|
||||||
|
if not self.ctx.game:
|
||||||
|
self.output(f"No game set, cannot determine {name}.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names
|
||||||
|
lookup = lookup[self.ctx.game]
|
||||||
|
self.output(f"{name} for {self.ctx.game}")
|
||||||
|
for name in lookup.values():
|
||||||
|
self.output(name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _cmd_items(self) -> bool:
|
||||||
"""List all item names for the currently running game."""
|
"""List all item names for the currently running game."""
|
||||||
if not self.ctx.game:
|
return self.output_datapackage_part("Item Names")
|
||||||
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_item_groups(self):
|
def _cmd_locations(self) -> bool:
|
||||||
"""List all item group names for the currently running game."""
|
|
||||||
if not self.ctx.game:
|
|
||||||
self.output("No game set, cannot determine existing item groups.")
|
|
||||||
return False
|
|
||||||
self.output(f"Item Group Names for {self.ctx.game}")
|
|
||||||
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
|
|
||||||
self.output(group_name)
|
|
||||||
|
|
||||||
def _cmd_locations(self):
|
|
||||||
"""List all location names for the currently running game."""
|
"""List all location names for the currently running game."""
|
||||||
if not self.ctx.game:
|
return self.output_datapackage_part("Location Names")
|
||||||
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)
|
|
||||||
|
|
||||||
def _cmd_location_groups(self):
|
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
|
||||||
"""List all location group names for the currently running game."""
|
filter_key: str,
|
||||||
if not self.ctx.game:
|
name: str) -> bool:
|
||||||
self.output("No game set, cannot determine existing location groups.")
|
"""
|
||||||
return False
|
Logs an item or location group from the player's game's datapackage.
|
||||||
self.output(f"Location Group Names for {self.ctx.game}")
|
|
||||||
for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
|
|
||||||
self.output(group_name)
|
|
||||||
|
|
||||||
def _cmd_ready(self):
|
:param group_key: Either Item or Location group to be processed.
|
||||||
|
:param filter_key: Which group key to filter to. If an empty string is passed will log all item/location groups.
|
||||||
|
:param name: Printed to the user as context for the part.
|
||||||
|
|
||||||
|
:return: Whether the process was successful.
|
||||||
|
"""
|
||||||
|
if not self.ctx.game:
|
||||||
|
self.output(f"No game set, cannot determine existing {name} Groups.")
|
||||||
|
return False
|
||||||
|
lookup = Utils.persistent_load().get("groups_by_checksum", {}).get(self.ctx.checksums[self.ctx.game], {})\
|
||||||
|
.get(self.ctx.game, {}).get(group_key, {})
|
||||||
|
if lookup is None:
|
||||||
|
self.output("datapackage not yet loaded, try again")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if filter_key:
|
||||||
|
if filter_key not in lookup:
|
||||||
|
self.output(f"Unknown {name} Group {filter_key}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.output(f"{name}s for {name} Group \"{filter_key}\"")
|
||||||
|
for entry in lookup[filter_key]:
|
||||||
|
self.output(entry)
|
||||||
|
else:
|
||||||
|
self.output(f"{name} Groups for {self.ctx.game}")
|
||||||
|
for group in lookup:
|
||||||
|
self.output(group)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@mark_raw
|
||||||
|
def _cmd_item_groups(self, key: str = "") -> bool:
|
||||||
|
"""
|
||||||
|
List all item group names for the currently running game.
|
||||||
|
|
||||||
|
:param key: Which item group to filter to. Will log all groups if empty.
|
||||||
|
"""
|
||||||
|
return self.output_group_part("item_name_groups", key, "Item")
|
||||||
|
|
||||||
|
@mark_raw
|
||||||
|
def _cmd_location_groups(self, key: str = "") -> bool:
|
||||||
|
"""
|
||||||
|
List all location group names for the currently running game.
|
||||||
|
|
||||||
|
:param key: Which item group to filter to. Will log all groups if empty.
|
||||||
|
"""
|
||||||
|
return self.output_group_part("location_name_groups", key, "Location")
|
||||||
|
|
||||||
|
def _cmd_ready(self) -> bool:
|
||||||
"""Send ready status to server."""
|
"""Send ready status to server."""
|
||||||
self.ctx.ready = not self.ctx.ready
|
self.ctx.ready = not self.ctx.ready
|
||||||
if self.ctx.ready:
|
if self.ctx.ready:
|
||||||
@@ -173,6 +220,7 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
state = ClientStatus.CLIENT_CONNECTED
|
state = ClientStatus.CLIENT_CONNECTED
|
||||||
self.output("Unreadied.")
|
self.output("Unreadied.")
|
||||||
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||||
|
return True
|
||||||
|
|
||||||
def default(self, raw: str):
|
def default(self, raw: str):
|
||||||
"""The default message parser to be used when parsing any messages that do not match a command"""
|
"""The default message parser to be used when parsing any messages that do not match a command"""
|
||||||
@@ -195,25 +243,12 @@ class CommonContext:
|
|||||||
self.lookup_type: typing.Literal["item", "location"] = lookup_type
|
self.lookup_type: typing.Literal["item", "location"] = lookup_type
|
||||||
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
|
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
|
||||||
self._archipelago_lookup: typing.Dict[int, str] = {}
|
self._archipelago_lookup: typing.Dict[int, str] = {}
|
||||||
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
|
|
||||||
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
|
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
|
||||||
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
|
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
|
||||||
self.warned: bool = False
|
|
||||||
|
|
||||||
# noinspection PyTypeChecker
|
# noinspection PyTypeChecker
|
||||||
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
||||||
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
|
assert isinstance(key, str), f"ctx.{self.lookup_type}_names used with an id, use the lookup_in_ helpers instead"
|
||||||
if isinstance(key, int):
|
|
||||||
if not self.warned:
|
|
||||||
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
|
|
||||||
self.warned = True
|
|
||||||
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
|
|
||||||
f"backwards compatibility for now. If multiple games share the same id for a "
|
|
||||||
f"{self.lookup_type}, name could be incorrect. Please use "
|
|
||||||
f"`{self.lookup_type}_names.lookup_in_game()` or "
|
|
||||||
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
|
|
||||||
return self._flat_store[key] # type: ignore
|
|
||||||
|
|
||||||
return self._game_store[key]
|
return self._game_store[key]
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
@@ -223,7 +258,7 @@ class CommonContext:
|
|||||||
return iter(self._game_store)
|
return iter(self._game_store)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return self._game_store.__repr__()
|
return repr(self._game_store)
|
||||||
|
|
||||||
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
|
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
|
||||||
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
|
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
|
||||||
@@ -253,7 +288,6 @@ class CommonContext:
|
|||||||
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
|
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
|
||||||
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
|
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
|
||||||
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
|
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
|
||||||
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
|
|
||||||
if game == "Archipelago":
|
if game == "Archipelago":
|
||||||
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
|
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
|
||||||
# it updates in all chain maps automatically.
|
# it updates in all chain maps automatically.
|
||||||
@@ -280,38 +314,71 @@ class CommonContext:
|
|||||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||||
|
|
||||||
# remaining type info
|
# remaining type info
|
||||||
slot_info: typing.Dict[int, NetworkSlot]
|
slot_info: dict[int, NetworkSlot]
|
||||||
server_address: typing.Optional[str]
|
"""Slot Info from the server for the current connection"""
|
||||||
password: typing.Optional[str]
|
server_address: str | None
|
||||||
hint_cost: typing.Optional[int]
|
"""Autoconnect address provided by the ctx constructor"""
|
||||||
hint_points: typing.Optional[int]
|
password: str | None
|
||||||
player_names: typing.Dict[int, str]
|
"""Password used for Connecting, expected by server_auth"""
|
||||||
|
hint_cost: int | None
|
||||||
|
"""Current Hint Cost per Hint from the server"""
|
||||||
|
hint_points: int | None
|
||||||
|
"""Current available Hint Points from the server"""
|
||||||
|
player_names: dict[int, str]
|
||||||
|
"""Current lookup of slot number to player display name from server (includes aliases)"""
|
||||||
|
|
||||||
finished_game: bool
|
finished_game: bool
|
||||||
|
"""
|
||||||
|
Bool to signal that status should be updated to Goal after reconnecting
|
||||||
|
to be used to ensure that a StatusUpdate packet does not get lost when disconnected
|
||||||
|
"""
|
||||||
ready: bool
|
ready: bool
|
||||||
team: typing.Optional[int]
|
"""Bool to keep track of state for the /ready command"""
|
||||||
slot: typing.Optional[int]
|
team: int | None
|
||||||
auth: typing.Optional[str]
|
"""Team number of currently connected slot"""
|
||||||
seed_name: typing.Optional[str]
|
slot: int | None
|
||||||
|
"""Slot number of currently connected slot"""
|
||||||
|
auth: str | None
|
||||||
|
"""Name used in Connect packet"""
|
||||||
|
seed_name: str | None
|
||||||
|
"""Seed name that will be validated on opening a socket if present"""
|
||||||
|
|
||||||
# locations
|
# locations
|
||||||
locations_checked: typing.Set[int] # local state
|
locations_checked: set[int]
|
||||||
locations_scouted: typing.Set[int]
|
"""
|
||||||
items_received: typing.List[NetworkItem]
|
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting
|
||||||
missing_locations: typing.Set[int] # server state
|
to be used to ensure that a LocationChecks packet does not get lost when disconnected
|
||||||
checked_locations: typing.Set[int] # server state
|
"""
|
||||||
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
locations_scouted: set[int]
|
||||||
locations_info: typing.Dict[int, NetworkItem]
|
"""
|
||||||
|
Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
|
||||||
|
to be used to ensure that a LocationScouts packet does not get lost when disconnected
|
||||||
|
"""
|
||||||
|
items_received: list[NetworkItem]
|
||||||
|
"""List of NetworkItems recieved from the server"""
|
||||||
|
missing_locations: set[int]
|
||||||
|
"""Container of Locations that are unchecked per server state"""
|
||||||
|
checked_locations: set[int]
|
||||||
|
"""Container of Locations that are checked per server state"""
|
||||||
|
server_locations: set[int]
|
||||||
|
"""Container of Locations that exist per server state; a combination between missing and checked locations"""
|
||||||
|
locations_info: dict[int, NetworkItem]
|
||||||
|
"""Dict of location id: NetworkItem info from LocationScouts request"""
|
||||||
|
|
||||||
# data storage
|
# data storage
|
||||||
stored_data: typing.Dict[str, typing.Any]
|
stored_data: dict[str, typing.Any]
|
||||||
stored_data_notification_keys: typing.Set[str]
|
"""
|
||||||
|
Data Storage values by key that were retrieved from the server
|
||||||
|
any keys subscribed to with SetNotify will be kept up to date
|
||||||
|
"""
|
||||||
|
stored_data_notification_keys: set[str]
|
||||||
|
"""Current container of watched Data Storage keys, managed by ctx.set_notify"""
|
||||||
|
|
||||||
# internals
|
# internals
|
||||||
# current message box through kvui
|
|
||||||
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
||||||
# message box reporting a loss of connection
|
"""Current message box through kvui"""
|
||||||
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
||||||
|
"""Message box reporting a loss of connection"""
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
||||||
# server state
|
# server state
|
||||||
@@ -355,11 +422,12 @@ class CommonContext:
|
|||||||
|
|
||||||
self.item_names = self.NameLookupDict(self, "item")
|
self.item_names = self.NameLookupDict(self, "item")
|
||||||
self.location_names = self.NameLookupDict(self, "location")
|
self.location_names = self.NameLookupDict(self, "location")
|
||||||
self.versions = {}
|
|
||||||
self.checksums = {}
|
self.checksums = {}
|
||||||
|
|
||||||
self.jsontotextparser = JSONtoTextParser(self)
|
self.jsontotextparser = JSONtoTextParser(self)
|
||||||
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
||||||
|
if self.game:
|
||||||
|
self.checksums[self.game] = network_data_package["games"][self.game]["checksum"]
|
||||||
self.update_data_package(network_data_package)
|
self.update_data_package(network_data_package)
|
||||||
|
|
||||||
# execution
|
# execution
|
||||||
@@ -412,6 +480,8 @@ class CommonContext:
|
|||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task is not None:
|
if self.server_task is not None:
|
||||||
await self.server_task
|
await self.server_task
|
||||||
|
if self.ui:
|
||||||
|
self.ui.update_hints()
|
||||||
|
|
||||||
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
||||||
""" `msgs` JSON serializable """
|
""" `msgs` JSON serializable """
|
||||||
@@ -458,6 +528,13 @@ class CommonContext:
|
|||||||
await self.send_msgs([payload])
|
await self.send_msgs([payload])
|
||||||
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
|
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
|
||||||
|
|
||||||
|
async def check_locations(self, locations: typing.Collection[int]) -> set[int]:
|
||||||
|
"""Send new location checks to the server. Returns the set of actually new locations that were sent."""
|
||||||
|
locations = set(locations) & self.missing_locations
|
||||||
|
if locations:
|
||||||
|
await self.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(locations)}])
|
||||||
|
return locations
|
||||||
|
|
||||||
async def console_input(self) -> str:
|
async def console_input(self) -> str:
|
||||||
if self.ui:
|
if self.ui:
|
||||||
self.ui.focus_textinput()
|
self.ui.focus_textinput()
|
||||||
@@ -495,6 +572,10 @@ class CommonContext:
|
|||||||
return print_json_packet.get("type", "") == "ItemSend" \
|
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["receiving"]) \
|
||||||
and not self.slot_concerns_self(print_json_packet["item"].player)
|
and not self.slot_concerns_self(print_json_packet["item"].player)
|
||||||
|
|
||||||
|
def is_connection_change(self, print_json_packet: dict) -> bool:
|
||||||
|
"""Helper function for filtering out connection changes."""
|
||||||
|
return print_json_packet.get("type", "") in ["Join","Part"]
|
||||||
|
|
||||||
def on_print(self, args: dict):
|
def on_print(self, args: dict):
|
||||||
logger.info(args["text"])
|
logger.info(args["text"])
|
||||||
@@ -551,10 +632,16 @@ class CommonContext:
|
|||||||
await self.ui_task
|
await self.ui_task
|
||||||
if self.input_task:
|
if self.input_task:
|
||||||
self.input_task.cancel()
|
self.input_task.cancel()
|
||||||
|
|
||||||
|
# Hints
|
||||||
|
def update_hint(self, location: int, finding_player: int, status: typing.Optional[HintStatus]) -> None:
|
||||||
|
msg = {"cmd": "UpdateHint", "location": location, "player": finding_player}
|
||||||
|
if status is not None:
|
||||||
|
msg["status"] = status
|
||||||
|
async_start(self.send_msgs([msg]), name="update_hint")
|
||||||
|
|
||||||
# DataPackage
|
# DataPackage
|
||||||
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
||||||
remote_date_package_versions: typing.Dict[str, int],
|
|
||||||
remote_data_package_checksums: typing.Dict[str, str]):
|
remote_data_package_checksums: typing.Dict[str, str]):
|
||||||
"""Validate that all data is present for the current multiworld.
|
"""Validate that all data is present for the current multiworld.
|
||||||
Download, assimilate and cache missing data from the server."""
|
Download, assimilate and cache missing data from the server."""
|
||||||
@@ -563,33 +650,26 @@ class CommonContext:
|
|||||||
|
|
||||||
needed_updates: typing.Set[str] = set()
|
needed_updates: typing.Set[str] = set()
|
||||||
for game in relevant_games:
|
for game in relevant_games:
|
||||||
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
|
if game not in remote_data_package_checksums:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
remote_version: int = remote_date_package_versions.get(game, 0)
|
|
||||||
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
||||||
|
|
||||||
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
|
if not remote_checksum: # custom data package and no checksum for this game
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cached_version: int = self.versions.get(game, 0)
|
|
||||||
cached_checksum: typing.Optional[str] = self.checksums.get(game)
|
cached_checksum: typing.Optional[str] = self.checksums.get(game)
|
||||||
# no action required if cached version is new enough
|
# no action required if cached version is new enough
|
||||||
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
|
if remote_checksum != cached_checksum:
|
||||||
or remote_checksum != cached_checksum:
|
|
||||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
|
||||||
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
||||||
if ((remote_checksum or remote_version <= local_version and remote_version != 0)
|
if remote_checksum == local_checksum:
|
||||||
and remote_checksum == local_checksum):
|
|
||||||
self.update_game(network_data_package["games"][game], game)
|
self.update_game(network_data_package["games"][game], game)
|
||||||
else:
|
else:
|
||||||
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
||||||
cache_version: int = cached_game.get("version", 0)
|
|
||||||
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
||||||
# download remote version if cache is not new enough
|
# download remote version if cache is not new enough
|
||||||
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
|
if remote_checksum != cache_checksum:
|
||||||
or remote_checksum != cache_checksum:
|
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
else:
|
else:
|
||||||
self.update_game(cached_game, game)
|
self.update_game(cached_game, game)
|
||||||
@@ -599,7 +679,6 @@ class CommonContext:
|
|||||||
def update_game(self, game_package: dict, game: str):
|
def update_game(self, game_package: dict, game: str):
|
||||||
self.item_names.update_game(game, game_package["item_name_to_id"])
|
self.item_names.update_game(game, game_package["item_name_to_id"])
|
||||||
self.location_names.update_game(game, game_package["location_name_to_id"])
|
self.location_names.update_game(game, game_package["location_name_to_id"])
|
||||||
self.versions[game] = game_package.get("version", 0)
|
|
||||||
self.checksums[game] = game_package.get("checksum")
|
self.checksums[game] = game_package.get("checksum")
|
||||||
|
|
||||||
def update_data_package(self, data_package: dict):
|
def update_data_package(self, data_package: dict):
|
||||||
@@ -608,13 +687,28 @@ class CommonContext:
|
|||||||
|
|
||||||
def consume_network_data_package(self, data_package: dict):
|
def consume_network_data_package(self, data_package: dict):
|
||||||
self.update_data_package(data_package)
|
self.update_data_package(data_package)
|
||||||
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
|
||||||
current_cache.update(data_package["games"])
|
|
||||||
Utils.persistent_store("datapackage", "games", current_cache)
|
|
||||||
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
|
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
|
||||||
for game, game_data in data_package["games"].items():
|
for game, game_data in data_package["games"].items():
|
||||||
Utils.store_data_package_for_checksum(game, game_data)
|
Utils.store_data_package_for_checksum(game, game_data)
|
||||||
|
|
||||||
|
def consume_network_item_groups(self):
|
||||||
|
data = {"item_name_groups": self.stored_data[f"_read_item_name_groups_{self.game}"]}
|
||||||
|
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
|
||||||
|
if self.game in current_cache:
|
||||||
|
current_cache[self.game].update(data)
|
||||||
|
else:
|
||||||
|
current_cache[self.game] = data
|
||||||
|
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
|
||||||
|
|
||||||
|
def consume_network_location_groups(self):
|
||||||
|
data = {"location_name_groups": self.stored_data[f"_read_location_name_groups_{self.game}"]}
|
||||||
|
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
|
||||||
|
if self.game in current_cache:
|
||||||
|
current_cache[self.game].update(data)
|
||||||
|
else:
|
||||||
|
current_cache[self.game] = data
|
||||||
|
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
|
||||||
|
|
||||||
# data storage
|
# data storage
|
||||||
|
|
||||||
def set_notify(self, *keys: str) -> None:
|
def set_notify(self, *keys: str) -> None:
|
||||||
@@ -693,8 +787,16 @@ class CommonContext:
|
|||||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||||
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
||||||
|
|
||||||
def make_gui(self) -> typing.Type["kvui.GameManager"]:
|
def make_gui(self) -> "type[kvui.GameManager]":
|
||||||
"""To return the Kivy App class needed for run_gui so it can be overridden before being built"""
|
"""
|
||||||
|
To return the Kivy `App` class needed for `run_gui` so it can be overridden before being built
|
||||||
|
|
||||||
|
Common changes are changing `base_title` to update the window title of the client and
|
||||||
|
updating `logging_pairs` to automatically make new tabs that can be filled with their respective logger.
|
||||||
|
|
||||||
|
ex. `logging_pairs.append(("Foo", "Bar"))`
|
||||||
|
will add a "Bar" tab which follows the logger returned from `logging.getLogger("Foo")`
|
||||||
|
"""
|
||||||
from kvui import GameManager
|
from kvui import GameManager
|
||||||
|
|
||||||
class TextManager(GameManager):
|
class TextManager(GameManager):
|
||||||
@@ -758,9 +860,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
|||||||
|
|
||||||
server_url = urllib.parse.urlparse(address)
|
server_url = urllib.parse.urlparse(address)
|
||||||
if server_url.username:
|
if server_url.username:
|
||||||
ctx.username = server_url.username
|
ctx.username = urllib.parse.unquote(server_url.username)
|
||||||
if server_url.password:
|
if server_url.password:
|
||||||
ctx.password = server_url.password
|
ctx.password = urllib.parse.unquote(server_url.password)
|
||||||
|
|
||||||
def reconnect_hint() -> str:
|
def reconnect_hint() -> str:
|
||||||
return ", type /connect to reconnect" if ctx.server_address else ""
|
return ", type /connect to reconnect" if ctx.server_address else ""
|
||||||
@@ -865,9 +967,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||||
|
|
||||||
# update data package
|
# update data package
|
||||||
data_package_versions = args.get("datapackage_versions", {})
|
|
||||||
data_package_checksums = args.get("datapackage_checksums", {})
|
data_package_checksums = args.get("datapackage_checksums", {})
|
||||||
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
|
await ctx.prepare_data_package(set(args["games"]), data_package_checksums)
|
||||||
|
|
||||||
await ctx.server_auth(args['password'])
|
await ctx.server_auth(args['password'])
|
||||||
|
|
||||||
@@ -883,6 +984,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.disconnected_intentionally = True
|
ctx.disconnected_intentionally = True
|
||||||
ctx.event_invalid_game()
|
ctx.event_invalid_game()
|
||||||
elif 'IncompatibleVersion' in errors:
|
elif 'IncompatibleVersion' in errors:
|
||||||
|
ctx.disconnected_intentionally = True
|
||||||
raise Exception('Server reported your client version as incompatible. '
|
raise Exception('Server reported your client version as incompatible. '
|
||||||
'This probably means you have to update.')
|
'This probably means you have to update.')
|
||||||
elif 'InvalidItemsHandling' in errors:
|
elif 'InvalidItemsHandling' in errors:
|
||||||
@@ -907,6 +1009,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.hint_points = args.get("hint_points", 0)
|
ctx.hint_points = args.get("hint_points", 0)
|
||||||
ctx.consume_players_package(args["players"])
|
ctx.consume_players_package(args["players"])
|
||||||
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
|
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
|
||||||
|
if ctx.game:
|
||||||
|
game = ctx.game
|
||||||
|
else:
|
||||||
|
game = ctx.slot_info[ctx.slot][1]
|
||||||
|
ctx.stored_data_notification_keys.add(f"_read_item_name_groups_{game}")
|
||||||
|
ctx.stored_data_notification_keys.add(f"_read_location_name_groups_{game}")
|
||||||
msgs = []
|
msgs = []
|
||||||
if ctx.locations_checked:
|
if ctx.locations_checked:
|
||||||
msgs.append({"cmd": "LocationChecks",
|
msgs.append({"cmd": "LocationChecks",
|
||||||
@@ -987,11 +1095,19 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.stored_data.update(args["keys"])
|
ctx.stored_data.update(args["keys"])
|
||||||
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
|
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
|
||||||
ctx.ui.update_hints()
|
ctx.ui.update_hints()
|
||||||
|
if f"_read_item_name_groups_{ctx.game}" in args["keys"]:
|
||||||
|
ctx.consume_network_item_groups()
|
||||||
|
if f"_read_location_name_groups_{ctx.game}" in args["keys"]:
|
||||||
|
ctx.consume_network_location_groups()
|
||||||
|
|
||||||
elif cmd == "SetReply":
|
elif cmd == "SetReply":
|
||||||
ctx.stored_data[args["key"]] = args["value"]
|
ctx.stored_data[args["key"]] = args["value"]
|
||||||
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
|
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
|
||||||
ctx.ui.update_hints()
|
ctx.ui.update_hints()
|
||||||
|
elif f"_read_item_name_groups_{ctx.game}" == args["key"]:
|
||||||
|
ctx.consume_network_item_groups()
|
||||||
|
elif f"_read_location_name_groups_{ctx.game}" == args["key"]:
|
||||||
|
ctx.consume_network_location_groups()
|
||||||
elif args["key"].startswith("EnergyLink"):
|
elif args["key"].startswith("EnergyLink"):
|
||||||
ctx.current_energy_link_value = args["value"]
|
ctx.current_energy_link_value = args["value"]
|
||||||
if ctx.ui:
|
if ctx.ui:
|
||||||
@@ -1033,6 +1149,32 @@ def get_base_parser(description: typing.Optional[str] = None):
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def handle_url_arg(args: "argparse.Namespace",
|
||||||
|
parser: "typing.Optional[argparse.ArgumentParser]" = None) -> "argparse.Namespace":
|
||||||
|
"""
|
||||||
|
Parse the url arg "archipelago://name:pass@host:port" from launcher into correct launch args for CommonClient
|
||||||
|
If alternate data is required the urlparse response is saved back to args.url if valid
|
||||||
|
"""
|
||||||
|
if not args.url:
|
||||||
|
return args
|
||||||
|
|
||||||
|
url = urllib.parse.urlparse(args.url)
|
||||||
|
if url.scheme != "archipelago":
|
||||||
|
if not parser:
|
||||||
|
parser = get_base_parser()
|
||||||
|
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
|
||||||
|
return args
|
||||||
|
|
||||||
|
args.url = 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)
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
def run_as_textclient(*args):
|
def run_as_textclient(*args):
|
||||||
class TextContext(CommonContext):
|
class TextContext(CommonContext):
|
||||||
# Text Mode to use !hint and such with games that have no text entry
|
# Text Mode to use !hint and such with games that have no text entry
|
||||||
@@ -1045,7 +1187,7 @@ def run_as_textclient(*args):
|
|||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
await super(TextContext, self).server_auth(password_requested)
|
await super(TextContext, self).server_auth(password_requested)
|
||||||
await self.get_username()
|
await self.get_username()
|
||||||
await self.send_connect()
|
await self.send_connect(game="")
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd == "Connected":
|
if cmd == "Connected":
|
||||||
@@ -1074,20 +1216,10 @@ def run_as_textclient(*args):
|
|||||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||||
args = parser.parse_args(args)
|
args = parser.parse_args(args)
|
||||||
|
|
||||||
# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
|
args = handle_url_arg(args, parser=parser)
|
||||||
if args.url:
|
|
||||||
url = urllib.parse.urlparse(args.url)
|
|
||||||
if url.scheme == "archipelago":
|
|
||||||
args.connect = url.netloc
|
|
||||||
if url.username:
|
|
||||||
args.name = urllib.parse.unquote(url.username)
|
|
||||||
if url.password:
|
|
||||||
args.password = urllib.parse.unquote(url.password)
|
|
||||||
else:
|
|
||||||
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
|
|
||||||
|
|
||||||
# use colorama to display colored text highlighting on windows
|
# use colorama to display colored text highlighting on windows
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main(args))
|
asyncio.run(main(args))
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
100
Dockerfile
Normal file
100
Dockerfile
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# hadolint global ignore=SC1090,SC1091
|
||||||
|
|
||||||
|
# Source
|
||||||
|
FROM scratch AS release
|
||||||
|
WORKDIR /release
|
||||||
|
ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip
|
||||||
|
|
||||||
|
# Enemizer
|
||||||
|
FROM alpine:3.21 AS enemizer
|
||||||
|
ARG TARGETARCH
|
||||||
|
WORKDIR /release
|
||||||
|
COPY --from=release /release/Enemizer.zip .
|
||||||
|
|
||||||
|
# No release for arm architecture. Skip.
|
||||||
|
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
|
apk add unzip=6.0-r15 --no-cache && \
|
||||||
|
unzip -u Enemizer.zip -d EnemizerCLI && \
|
||||||
|
chmod -R 777 EnemizerCLI; \
|
||||||
|
else touch EnemizerCLI; fi
|
||||||
|
|
||||||
|
# Cython builder stage
|
||||||
|
FROM python:3.12 AS cython-builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy and install requirements first (better caching)
|
||||||
|
COPY requirements.txt WebHostLib/requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r \
|
||||||
|
WebHostLib/requirements.txt \
|
||||||
|
"setuptools>=75,<81"
|
||||||
|
|
||||||
|
COPY _speedups.pyx .
|
||||||
|
COPY intset.h .
|
||||||
|
|
||||||
|
RUN cythonize -b -i _speedups.pyx
|
||||||
|
|
||||||
|
# Archipelago
|
||||||
|
FROM python:3.12-slim-bookworm AS archipelago
|
||||||
|
ARG TARGETARCH
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install requirements
|
||||||
|
# hadolint ignore=DL3008
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
gcc=4:12.2.0-3 \
|
||||||
|
libc6-dev \
|
||||||
|
libtk8.6=8.6.13-2 \
|
||||||
|
g++=4:12.2.0-3 \
|
||||||
|
curl && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create and activate venv
|
||||||
|
RUN python -m venv $VIRTUAL_ENV; \
|
||||||
|
. $VIRTUAL_ENV/bin/activate
|
||||||
|
|
||||||
|
# Copy and install requirements first (better caching)
|
||||||
|
COPY WebHostLib/requirements.txt WebHostLib/requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r \
|
||||||
|
WebHostLib/requirements.txt \
|
||||||
|
gunicorn==23.0.0
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
COPY --from=cython-builder /build/*.so ./
|
||||||
|
|
||||||
|
# Run ModuleUpdate
|
||||||
|
RUN python ModuleUpdate.py -y
|
||||||
|
|
||||||
|
# Purge unneeded packages
|
||||||
|
RUN apt-get purge -y \
|
||||||
|
git \
|
||||||
|
gcc \
|
||||||
|
libc6-dev \
|
||||||
|
g++ && \
|
||||||
|
apt-get autoremove -y
|
||||||
|
|
||||||
|
# Copy necessary components
|
||||||
|
COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI
|
||||||
|
|
||||||
|
# No release for arm architecture. Skip.
|
||||||
|
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
|
cp -r /tmp/EnemizerCLI EnemizerCLI; \
|
||||||
|
fi; \
|
||||||
|
rm -rf /tmp/EnemizerCLI
|
||||||
|
|
||||||
|
# Define health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:${PORT:-80} || exit 1
|
||||||
|
|
||||||
|
# Ensure no runtime ModuleUpdate.
|
||||||
|
ENV SKIP_REQUIREMENTS_UPDATE=true
|
||||||
|
|
||||||
|
ENTRYPOINT [ "python", "WebHost.py" ]
|
||||||
267
FF1Client.py
267
FF1Client.py
@@ -1,267 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import copy
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from asyncio import StreamReader, StreamWriter
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
|
|
||||||
import Utils
|
|
||||||
from Utils import async_start
|
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
|
||||||
get_base_parser
|
|
||||||
|
|
||||||
SYSTEM_MESSAGE_ID = 0
|
|
||||||
|
|
||||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
|
|
||||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
|
|
||||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
|
|
||||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
|
||||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
|
||||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
|
||||||
|
|
||||||
DISPLAY_MSGS = True
|
|
||||||
|
|
||||||
|
|
||||||
class FF1CommandProcessor(ClientCommandProcessor):
|
|
||||||
def __init__(self, ctx: CommonContext):
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def _cmd_nes(self):
|
|
||||||
"""Check NES Connection State"""
|
|
||||||
if isinstance(self.ctx, FF1Context):
|
|
||||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
|
||||||
|
|
||||||
def _cmd_toggle_msgs(self):
|
|
||||||
"""Toggle displaying messages in EmuHawk"""
|
|
||||||
global DISPLAY_MSGS
|
|
||||||
DISPLAY_MSGS = not DISPLAY_MSGS
|
|
||||||
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
|
||||||
|
|
||||||
|
|
||||||
class FF1Context(CommonContext):
|
|
||||||
command_processor = FF1CommandProcessor
|
|
||||||
game = 'Final Fantasy'
|
|
||||||
items_handling = 0b111 # full remote
|
|
||||||
|
|
||||||
def __init__(self, server_address, password):
|
|
||||||
super().__init__(server_address, password)
|
|
||||||
self.nes_streams: (StreamReader, StreamWriter) = None
|
|
||||||
self.nes_sync_task = None
|
|
||||||
self.messages = {}
|
|
||||||
self.locations_array = None
|
|
||||||
self.nes_status = CONNECTION_INITIAL_STATUS
|
|
||||||
self.awaiting_rom = False
|
|
||||||
self.display_msgs = True
|
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
|
||||||
if password_requested and not self.password:
|
|
||||||
await super(FF1Context, self).server_auth(password_requested)
|
|
||||||
if not self.auth:
|
|
||||||
self.awaiting_rom = True
|
|
||||||
logger.info('Awaiting connection to NES to get Player information')
|
|
||||||
return
|
|
||||||
|
|
||||||
await self.send_connect()
|
|
||||||
|
|
||||||
def _set_message(self, msg: str, msg_id: int):
|
|
||||||
if DISPLAY_MSGS:
|
|
||||||
self.messages[time.time(), msg_id] = msg
|
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
|
||||||
if cmd == 'Connected':
|
|
||||||
async_start(parse_locations(self.locations_array, self, True))
|
|
||||||
elif cmd == 'Print':
|
|
||||||
msg = args['text']
|
|
||||||
if ': !' not in msg:
|
|
||||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
|
||||||
|
|
||||||
def on_print_json(self, args: dict):
|
|
||||||
if self.ui:
|
|
||||||
self.ui.print_json(copy.deepcopy(args["data"]))
|
|
||||||
else:
|
|
||||||
text = self.jsontotextparser(copy.deepcopy(args["data"]))
|
|
||||||
logger.info(text)
|
|
||||||
relevant = args.get("type", None) in {"Hint", "ItemSend"}
|
|
||||||
if relevant:
|
|
||||||
item = args["item"]
|
|
||||||
# goes to this world
|
|
||||||
if self.slot_concerns_self(args["receiving"]):
|
|
||||||
relevant = True
|
|
||||||
# found in this world
|
|
||||||
elif self.slot_concerns_self(item.player):
|
|
||||||
relevant = True
|
|
||||||
# not related
|
|
||||||
else:
|
|
||||||
relevant = False
|
|
||||||
if relevant:
|
|
||||||
item = args["item"]
|
|
||||||
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
|
|
||||||
self._set_message(msg, item.item)
|
|
||||||
|
|
||||||
def run_gui(self):
|
|
||||||
from kvui import GameManager
|
|
||||||
|
|
||||||
class FF1Manager(GameManager):
|
|
||||||
logging_pairs = [
|
|
||||||
("Client", "Archipelago")
|
|
||||||
]
|
|
||||||
base_title = "Archipelago Final Fantasy 1 Client"
|
|
||||||
|
|
||||||
self.ui = FF1Manager(self)
|
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
|
||||||
|
|
||||||
|
|
||||||
def get_payload(ctx: FF1Context):
|
|
||||||
current_time = time.time()
|
|
||||||
return json.dumps(
|
|
||||||
{
|
|
||||||
"items": [item.item for item in ctx.items_received],
|
|
||||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
|
||||||
if key[0] > current_time - 10}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
|
|
||||||
if locations_array == ctx.locations_array and not force:
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
# print("New values")
|
|
||||||
ctx.locations_array = locations_array
|
|
||||||
locations_checked = []
|
|
||||||
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
|
|
||||||
await ctx.send_msgs([
|
|
||||||
{"cmd": "StatusUpdate",
|
|
||||||
"status": 30}
|
|
||||||
])
|
|
||||||
ctx.finished_game = True
|
|
||||||
for location in ctx.missing_locations:
|
|
||||||
# index will be - 0x100 or 0x200
|
|
||||||
index = location
|
|
||||||
if location < 0x200:
|
|
||||||
# Location is a chest
|
|
||||||
index -= 0x100
|
|
||||||
flag = 0x04
|
|
||||||
else:
|
|
||||||
# Location is an NPC
|
|
||||||
index -= 0x200
|
|
||||||
flag = 0x02
|
|
||||||
|
|
||||||
# print(f"Location: {ctx.location_names[location]}")
|
|
||||||
# print(f"Index: {str(hex(index))}")
|
|
||||||
# print(f"value: {locations_array[index] & flag != 0}")
|
|
||||||
if locations_array[index] & flag != 0:
|
|
||||||
locations_checked.append(location)
|
|
||||||
if locations_checked:
|
|
||||||
# print([ctx.location_names[location] for location in locations_checked])
|
|
||||||
await ctx.send_msgs([
|
|
||||||
{"cmd": "LocationChecks",
|
|
||||||
"locations": locations_checked}
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
async def nes_sync_task(ctx: FF1Context):
|
|
||||||
logger.info("Starting nes connector. Use /nes for status information")
|
|
||||||
while not ctx.exit_event.is_set():
|
|
||||||
error_status = None
|
|
||||||
if ctx.nes_streams:
|
|
||||||
(reader, writer) = ctx.nes_streams
|
|
||||||
msg = get_payload(ctx).encode()
|
|
||||||
writer.write(msg)
|
|
||||||
writer.write(b'\n')
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
|
||||||
try:
|
|
||||||
# Data will return a dict with up to two fields:
|
|
||||||
# 1. A keepalive response of the Players Name (always)
|
|
||||||
# 2. An array representing the memory values of the locations area (if in game)
|
|
||||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
|
||||||
data_decoded = json.loads(data.decode())
|
|
||||||
# print(data_decoded)
|
|
||||||
if ctx.game is not None and 'locations' in data_decoded:
|
|
||||||
# Not just a keep alive ping, parse
|
|
||||||
async_start(parse_locations(data_decoded['locations'], ctx, False))
|
|
||||||
if not ctx.auth:
|
|
||||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
|
||||||
if ctx.auth == '':
|
|
||||||
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
|
|
||||||
"the ROM using the same link but adding your slot name")
|
|
||||||
if ctx.awaiting_rom:
|
|
||||||
await ctx.server_auth(False)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.debug("Read Timed Out, Reconnecting")
|
|
||||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.nes_streams = None
|
|
||||||
except ConnectionResetError as e:
|
|
||||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.nes_streams = None
|
|
||||||
except TimeoutError:
|
|
||||||
logger.debug("Connection Timed Out, Reconnecting")
|
|
||||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.nes_streams = None
|
|
||||||
except ConnectionResetError:
|
|
||||||
logger.debug("Connection Lost, Reconnecting")
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.nes_streams = None
|
|
||||||
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
|
|
||||||
if not error_status:
|
|
||||||
logger.info("Successfully Connected to NES")
|
|
||||||
ctx.nes_status = CONNECTION_CONNECTED_STATUS
|
|
||||||
else:
|
|
||||||
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
|
|
||||||
elif error_status:
|
|
||||||
ctx.nes_status = error_status
|
|
||||||
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
logger.debug("Attempting to connect to NES")
|
|
||||||
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
|
|
||||||
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
|
|
||||||
except TimeoutError:
|
|
||||||
logger.debug("Connection Timed Out, Trying Again")
|
|
||||||
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
continue
|
|
||||||
except ConnectionRefusedError:
|
|
||||||
logger.debug("Connection Refused, Trying Again")
|
|
||||||
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Text Mode to use !hint and such with games that have no text entry
|
|
||||||
Utils.init_logging("FF1Client")
|
|
||||||
|
|
||||||
options = Utils.get_options()
|
|
||||||
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
|
|
||||||
|
|
||||||
async def main(args):
|
|
||||||
ctx = FF1Context(args.connect, args.password)
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
|
||||||
if gui_enabled:
|
|
||||||
ctx.run_gui()
|
|
||||||
ctx.run_cli()
|
|
||||||
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
|
|
||||||
|
|
||||||
await ctx.exit_event.wait()
|
|
||||||
ctx.server_address = None
|
|
||||||
|
|
||||||
await ctx.shutdown()
|
|
||||||
|
|
||||||
if ctx.nes_sync_task:
|
|
||||||
await ctx.nes_sync_task
|
|
||||||
|
|
||||||
|
|
||||||
import colorama
|
|
||||||
|
|
||||||
parser = get_base_parser()
|
|
||||||
args = parser.parse_args()
|
|
||||||
colorama.init()
|
|
||||||
|
|
||||||
asyncio.run(main(args))
|
|
||||||
colorama.deinit()
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
from worlds.factorio.Client import check_stdin, launch
|
|
||||||
import Utils
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
|
||||||
check_stdin()
|
|
||||||
launch()
|
|
||||||
541
Fill.py
541
Fill.py
@@ -4,7 +4,7 @@ import logging
|
|||||||
import typing
|
import typing
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
|
|
||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
|
||||||
from Options import Accessibility
|
from Options import Accessibility
|
||||||
|
|
||||||
from worlds.AutoWorld import call_all
|
from worlds.AutoWorld import call_all
|
||||||
@@ -36,7 +36,8 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
|||||||
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||||
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
||||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
||||||
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
|
allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True,
|
||||||
|
name: str = "Unknown") -> None:
|
||||||
"""
|
"""
|
||||||
:param multiworld: Multiworld to be filled.
|
:param multiworld: Multiworld to be filled.
|
||||||
:param base_state: State assumed before fill.
|
:param base_state: State assumed before fill.
|
||||||
@@ -63,14 +64,24 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
placed = 0
|
placed = 0
|
||||||
|
|
||||||
while any(reachable_items.values()) and locations:
|
while any(reachable_items.values()) and locations:
|
||||||
# grab one item per player
|
if one_item_per_player:
|
||||||
items_to_place = [items.pop()
|
# grab one item per player
|
||||||
for items in reachable_items.values() if items]
|
items_to_place = [items.pop()
|
||||||
|
for items in reachable_items.values() if items]
|
||||||
|
else:
|
||||||
|
next_player = multiworld.random.choice([player for player, items in reachable_items.items() if items])
|
||||||
|
items_to_place = []
|
||||||
|
if item_pool:
|
||||||
|
items_to_place.append(reachable_items[next_player].pop())
|
||||||
|
|
||||||
for item in items_to_place:
|
for item in items_to_place:
|
||||||
for p, pool_item in enumerate(item_pool):
|
# The items added into `reachable_items` are placed starting from the end of each deque in
|
||||||
|
# `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`.
|
||||||
|
for p, pool_item in enumerate(reversed(item_pool), start=1):
|
||||||
if pool_item is item:
|
if pool_item is item:
|
||||||
item_pool.pop(p)
|
del item_pool[-p]
|
||||||
break
|
break
|
||||||
|
|
||||||
maximum_exploration_state = sweep_from_pool(
|
maximum_exploration_state = sweep_from_pool(
|
||||||
base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
|
base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
|
||||||
if single_player_placement else None)
|
if single_player_placement else None)
|
||||||
@@ -89,7 +100,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
# if minimal accessibility, only check whether location is reachable if game not beatable
|
# if minimal accessibility, only check whether location is reachable if game not beatable
|
||||||
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
||||||
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
|
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
|
||||||
item_to_place.player) \
|
item_to_place.player) \
|
||||||
if single_player_placement else not has_beaten_game
|
if single_player_placement else not has_beaten_game
|
||||||
else:
|
else:
|
||||||
perform_access_check = True
|
perform_access_check = True
|
||||||
@@ -105,12 +116,23 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
else:
|
else:
|
||||||
# we filled all reachable spots.
|
# we filled all reachable spots.
|
||||||
if swap:
|
if swap:
|
||||||
|
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
|
||||||
|
# swap state, instead of sweeping from `base_state` each time.
|
||||||
|
previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque()
|
||||||
|
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
|
||||||
|
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
|
||||||
|
max_swap_base_state_cache_length = 3
|
||||||
|
|
||||||
# try swapping this item with previously placed items in a safe way then in an unsafe way
|
# try swapping this item with previously placed items in a safe way then in an unsafe way
|
||||||
swap_attempts = ((i, location, unsafe)
|
swap_attempts = ((i, location, unsafe)
|
||||||
for unsafe in (False, True)
|
for unsafe in (False, True)
|
||||||
for i, location in enumerate(placements))
|
for i, location in enumerate(placements))
|
||||||
for (i, location, unsafe) in swap_attempts:
|
for (i, location, unsafe) in swap_attempts:
|
||||||
placed_item = location.item
|
placed_item = location.item
|
||||||
|
if item_to_place == placed_item:
|
||||||
|
# The number of allowed swaps is limited, so do not allow a swap of an item with a copy of
|
||||||
|
# itself.
|
||||||
|
continue
|
||||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||||
# number of times we will swap an individual item to prevent this
|
# number of times we will swap an individual item to prevent this
|
||||||
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
|
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
|
||||||
@@ -119,40 +141,50 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
|
|
||||||
location.item = None
|
location.item = None
|
||||||
placed_item.location = None
|
placed_item.location = None
|
||||||
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
|
|
||||||
multiworld.get_filled_locations(item.player)
|
for previous_safe_swap_state in previous_safe_swap_state_cache:
|
||||||
if single_player_placement else None)
|
# If a state has already checked the location of the swap, then it cannot be used.
|
||||||
|
if location not in previous_safe_swap_state.advancements:
|
||||||
|
# Previous swap states will have collected all items in `item_pool`, so the new
|
||||||
|
# `swap_state` can skip having to collect them again.
|
||||||
|
# Previous swap states will also have already checked many locations, making the sweep
|
||||||
|
# faster.
|
||||||
|
swap_state = sweep_from_pool(previous_safe_swap_state, (placed_item,) if unsafe else (),
|
||||||
|
multiworld.get_filled_locations(item.player)
|
||||||
|
if single_player_placement else None)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# No previous swap_state was usable as a base state to sweep from, so create a new one.
|
||||||
|
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
|
||||||
|
multiworld.get_filled_locations(item.player)
|
||||||
|
if single_player_placement else None)
|
||||||
|
# Unsafe states should not be added to the cache because they have collected `placed_item`.
|
||||||
|
if not unsafe:
|
||||||
|
if len(previous_safe_swap_state_cache) >= max_swap_base_state_cache_length:
|
||||||
|
# Remove the oldest cached state.
|
||||||
|
previous_safe_swap_state_cache.pop()
|
||||||
|
# Add the new state to the start of the cache.
|
||||||
|
previous_safe_swap_state_cache.appendleft(swap_state)
|
||||||
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
|
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
|
||||||
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
|
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
|
||||||
# to clean that up later, so there is a chance generation fails.
|
# to clean that up later, so there is a chance generation fails.
|
||||||
if (not single_player_placement or location.player == item_to_place.player) \
|
if (not single_player_placement or location.player == item_to_place.player) \
|
||||||
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
||||||
|
# Add this item to the existing placement, and
|
||||||
|
# add the old item to the back of the queue
|
||||||
|
spot_to_fill = placements.pop(i)
|
||||||
|
|
||||||
# Verify placing this item won't reduce available locations, which would be a useless swap.
|
swap_count += 1
|
||||||
prev_state = swap_state.copy()
|
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
|
||||||
prev_loc_count = len(
|
|
||||||
multiworld.get_reachable_locations(prev_state))
|
|
||||||
|
|
||||||
swap_state.collect(item_to_place, True)
|
reachable_items[placed_item.player].appendleft(
|
||||||
new_loc_count = len(
|
placed_item)
|
||||||
multiworld.get_reachable_locations(swap_state))
|
item_pool.append(placed_item)
|
||||||
|
|
||||||
if new_loc_count >= prev_loc_count:
|
# cleanup at the end to hopefully get better errors
|
||||||
# Add this item to the existing placement, and
|
cleanup_required = True
|
||||||
# add the old item to the back of the queue
|
|
||||||
spot_to_fill = placements.pop(i)
|
|
||||||
|
|
||||||
swap_count += 1
|
break
|
||||||
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
|
|
||||||
|
|
||||||
reachable_items[placed_item.player].appendleft(
|
|
||||||
placed_item)
|
|
||||||
item_pool.append(placed_item)
|
|
||||||
|
|
||||||
# cleanup at the end to hopefully get better errors
|
|
||||||
cleanup_required = True
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
# Item can't be placed here, restore original item
|
# Item can't be placed here, restore original item
|
||||||
location.item = placed_item
|
location.item = placed_item
|
||||||
@@ -226,18 +258,30 @@ def remaining_fill(multiworld: MultiWorld,
|
|||||||
locations: typing.List[Location],
|
locations: typing.List[Location],
|
||||||
itempool: typing.List[Item],
|
itempool: typing.List[Item],
|
||||||
name: str = "Remaining",
|
name: str = "Remaining",
|
||||||
move_unplaceable_to_start_inventory: bool = False) -> None:
|
move_unplaceable_to_start_inventory: bool = False,
|
||||||
|
check_location_can_fill: bool = False) -> None:
|
||||||
unplaced_items: typing.List[Item] = []
|
unplaced_items: typing.List[Item] = []
|
||||||
placements: typing.List[Location] = []
|
placements: typing.List[Location] = []
|
||||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||||
total = min(len(itempool), len(locations))
|
total = min(len(itempool), len(locations))
|
||||||
placed = 0
|
placed = 0
|
||||||
|
|
||||||
|
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
|
||||||
|
if check_location_can_fill:
|
||||||
|
state = CollectionState(multiworld)
|
||||||
|
|
||||||
|
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
|
||||||
|
return location_to_fill.can_fill(state, item_to_fill, check_access=False)
|
||||||
|
else:
|
||||||
|
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
|
||||||
|
return location_to_fill.item_rule(item_to_fill)
|
||||||
|
|
||||||
while locations and itempool:
|
while locations and itempool:
|
||||||
item_to_place = itempool.pop()
|
item_to_place = itempool.pop()
|
||||||
spot_to_fill: typing.Optional[Location] = None
|
spot_to_fill: typing.Optional[Location] = None
|
||||||
|
|
||||||
for i, location in enumerate(locations):
|
for i, location in enumerate(locations):
|
||||||
if location.item_rule(item_to_place):
|
if location_can_fill_item(location, item_to_place):
|
||||||
# popping by index is faster than removing by content,
|
# popping by index is faster than removing by content,
|
||||||
spot_to_fill = locations.pop(i)
|
spot_to_fill = locations.pop(i)
|
||||||
# skipping a scan for the element
|
# skipping a scan for the element
|
||||||
@@ -258,7 +302,7 @@ def remaining_fill(multiworld: MultiWorld,
|
|||||||
|
|
||||||
location.item = None
|
location.item = None
|
||||||
placed_item.location = None
|
placed_item.location = None
|
||||||
if location.item_rule(item_to_place):
|
if location_can_fill_item(location, item_to_place):
|
||||||
# Add this item to the existing placement, and
|
# Add this item to the existing placement, and
|
||||||
# add the old item to the back of the queue
|
# add the old item to the back of the queue
|
||||||
spot_to_fill = placements.pop(i)
|
spot_to_fill = placements.pop(i)
|
||||||
@@ -318,19 +362,26 @@ def fast_fill(multiworld: MultiWorld,
|
|||||||
return item_pool[placing:], fill_locations[placing:]
|
return item_pool[placing:], fill_locations[placing:]
|
||||||
|
|
||||||
|
|
||||||
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
|
def accessibility_corrections(multiworld: MultiWorld,
|
||||||
|
state: CollectionState,
|
||||||
|
locations: list[Location],
|
||||||
|
pool: list[Item] | None = None) -> None:
|
||||||
|
if pool is None:
|
||||||
|
pool = []
|
||||||
maximum_exploration_state = sweep_from_pool(state, pool)
|
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||||
minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
|
minimal_players = {player for player in multiworld.player_ids if
|
||||||
unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
|
multiworld.worlds[player].options.accessibility == "minimal"}
|
||||||
|
unreachable_locations = [location for location in multiworld.get_locations() if
|
||||||
|
location.player in minimal_players and
|
||||||
not location.can_reach(maximum_exploration_state)]
|
not location.can_reach(maximum_exploration_state)]
|
||||||
for location in unreachable_locations:
|
for location in unreachable_locations:
|
||||||
if (location.item is not None and location.item.advancement and location.address is not None and not
|
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):
|
location.locked and location.item.player not in minimal_players):
|
||||||
pool.append(location.item)
|
pool.append(location.item)
|
||||||
state.remove(location.item)
|
|
||||||
location.item = None
|
location.item = None
|
||||||
if location in state.advancements:
|
if location in state.advancements:
|
||||||
state.advancements.remove(location)
|
state.advancements.remove(location)
|
||||||
|
state.remove(location.item)
|
||||||
locations.append(location)
|
locations.append(location)
|
||||||
if pool and locations:
|
if pool and locations:
|
||||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||||
@@ -342,7 +393,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState,
|
|||||||
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
||||||
if unreachable_locations:
|
if unreachable_locations:
|
||||||
def forbid_important_item_rule(item: Item):
|
def forbid_important_item_rule(item: Item):
|
||||||
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
|
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
|
||||||
|
|
||||||
for location in unreachable_locations:
|
for location in unreachable_locations:
|
||||||
add_item_rule(location, forbid_important_item_rule)
|
add_item_rule(location, forbid_important_item_rule)
|
||||||
@@ -436,6 +487,12 @@ def distribute_early_items(multiworld: MultiWorld,
|
|||||||
|
|
||||||
def distribute_items_restrictive(multiworld: MultiWorld,
|
def distribute_items_restrictive(multiworld: MultiWorld,
|
||||||
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
||||||
|
assert all(item.location is None for item in multiworld.itempool), (
|
||||||
|
"At the start of distribute_items_restrictive, "
|
||||||
|
"there are items in the multiworld itempool that are already placed on locations:\n"
|
||||||
|
f"{[(item.location, item) for item in multiworld.itempool if item.location is not None]}"
|
||||||
|
)
|
||||||
|
|
||||||
fill_locations = sorted(multiworld.get_unfilled_locations())
|
fill_locations = sorted(multiworld.get_unfilled_locations())
|
||||||
multiworld.random.shuffle(fill_locations)
|
multiworld.random.shuffle(fill_locations)
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
@@ -478,22 +535,64 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
single_player = multiworld.players == 1 and not multiworld.groups
|
single_player = multiworld.players == 1 and not multiworld.groups
|
||||||
|
|
||||||
if prioritylocations:
|
if prioritylocations:
|
||||||
|
regular_progression = []
|
||||||
|
deprioritized_progression = []
|
||||||
|
for item in progitempool:
|
||||||
|
if item.deprioritized:
|
||||||
|
deprioritized_progression.append(item)
|
||||||
|
else:
|
||||||
|
regular_progression.append(item)
|
||||||
|
|
||||||
# "priority fill"
|
# "priority fill"
|
||||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
# try without deprioritized items in the mix at all. This means they need to be collected into state first.
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority")
|
priority_fill_state = sweep_from_pool(multiworld.state, deprioritized_progression)
|
||||||
|
fill_restrictive(multiworld, priority_fill_state, prioritylocations, regular_progression,
|
||||||
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
|
name="Priority", one_item_per_player=True, allow_partial=True)
|
||||||
|
|
||||||
|
if prioritylocations and regular_progression:
|
||||||
|
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
||||||
|
# deprioritized items are still not in the mix, so they need to be collected into state first.
|
||||||
|
# allow_partial should only be set if there is deprioritized progression to fall back on.
|
||||||
|
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
|
||||||
|
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
|
||||||
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
|
name="Priority Retry", one_item_per_player=False,
|
||||||
|
allow_partial=bool(deprioritized_progression))
|
||||||
|
|
||||||
|
if prioritylocations and deprioritized_progression:
|
||||||
|
# There are no more regular progression items that can be placed on any priority locations.
|
||||||
|
# We'd still prefer to place deprioritized progression items on priority locations over filler items.
|
||||||
|
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
|
||||||
|
priority_retry_2_state = sweep_from_pool(multiworld.state, regular_progression)
|
||||||
|
fill_restrictive(multiworld, priority_retry_2_state, prioritylocations, deprioritized_progression,
|
||||||
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
|
name="Priority Retry 2", one_item_per_player=True, allow_partial=True)
|
||||||
|
|
||||||
|
if prioritylocations and deprioritized_progression:
|
||||||
|
# retry with deprioritized items AND without one_item_per_player optimisation
|
||||||
|
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
|
||||||
|
priority_retry_3_state = sweep_from_pool(multiworld.state, regular_progression)
|
||||||
|
fill_restrictive(multiworld, priority_retry_3_state, prioritylocations, deprioritized_progression,
|
||||||
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
|
name="Priority Retry 3", one_item_per_player=False)
|
||||||
|
|
||||||
|
# restore original order of progitempool
|
||||||
|
progitempool[:] = [item for item in progitempool if not item.location]
|
||||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||||
defaultlocations = prioritylocations + defaultlocations
|
defaultlocations = prioritylocations + defaultlocations
|
||||||
|
|
||||||
if progitempool:
|
if progitempool:
|
||||||
# "advancement/progression fill"
|
# "advancement/progression fill"
|
||||||
|
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
||||||
if panic_method == "swap":
|
if panic_method == "swap":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True,
|
||||||
name="Progression", single_player_placement=single_player)
|
name="Progression", single_player_placement=single_player)
|
||||||
elif panic_method == "raise":
|
elif panic_method == "raise":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
||||||
name="Progression", single_player_placement=single_player)
|
name="Progression", single_player_placement=single_player)
|
||||||
elif panic_method == "start_inventory":
|
elif panic_method == "start_inventory":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
||||||
allow_partial=True, name="Progression", single_player_placement=single_player)
|
allow_partial=True, name="Progression", single_player_placement=single_player)
|
||||||
if progitempool:
|
if progitempool:
|
||||||
for item in progitempool:
|
for item in progitempool:
|
||||||
@@ -509,7 +608,8 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
if progitempool:
|
if progitempool:
|
||||||
raise FillError(
|
raise FillError(
|
||||||
f"Not enough locations for progression items. "
|
f"Not enough locations for progression items. "
|
||||||
f"There are {len(progitempool)} more progression items than there are available locations.",
|
f"There are {len(progitempool)} more progression items than there are available locations.\n"
|
||||||
|
f"Unfilled locations:\n{multiworld.get_unfilled_locations()}.",
|
||||||
multiworld=multiworld,
|
multiworld=multiworld,
|
||||||
)
|
)
|
||||||
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
|
||||||
@@ -527,7 +627,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
if excludedlocations:
|
if excludedlocations:
|
||||||
raise FillError(
|
raise FillError(
|
||||||
f"Not enough filler items for excluded locations. "
|
f"Not enough filler items for excluded locations. "
|
||||||
f"There are {len(excludedlocations)} more excluded locations than filler or trap items.",
|
f"There are {len(excludedlocations)} more excluded locations than excludable items.",
|
||||||
multiworld=multiworld,
|
multiworld=multiworld,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -548,6 +648,26 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
print_data = {"items": items_counter, "locations": locations_counter}
|
print_data = {"items": items_counter, "locations": locations_counter}
|
||||||
logging.info(f"Per-Player counts: {print_data})")
|
logging.info(f"Per-Player counts: {print_data})")
|
||||||
|
|
||||||
|
more_locations = locations_counter - items_counter
|
||||||
|
more_items = items_counter - locations_counter
|
||||||
|
for player in multiworld.player_ids:
|
||||||
|
if more_locations[player]:
|
||||||
|
logging.error(
|
||||||
|
f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
|
||||||
|
elif more_items[player]:
|
||||||
|
logging.warning(
|
||||||
|
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
|
||||||
|
if unfilled:
|
||||||
|
raise FillError(
|
||||||
|
f"Unable to fill all locations.\n" +
|
||||||
|
f"Unfilled locations({len(unfilled)}): {unfilled}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
f"Unable to place all items.\n" +
|
||||||
|
f"Unplaced items({len(unplaced)}): {unplaced}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def flood_items(multiworld: MultiWorld) -> None:
|
def flood_items(multiworld: MultiWorld) -> None:
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
@@ -623,9 +743,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
if multiworld.worlds[player].options.progression_balancing > 0
|
if multiworld.worlds[player].options.progression_balancing > 0
|
||||||
}
|
}
|
||||||
if not balanceable_players:
|
if not balanceable_players:
|
||||||
logging.info('Skipping multiworld progression balancing.')
|
logging.info("Skipping multiworld progression balancing.")
|
||||||
else:
|
else:
|
||||||
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
|
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
|
||||||
logging.debug(balanceable_players)
|
logging.debug(balanceable_players)
|
||||||
state: CollectionState = CollectionState(multiworld)
|
state: CollectionState = CollectionState(multiworld)
|
||||||
checked_locations: typing.Set[Location] = set()
|
checked_locations: typing.Set[Location] = set()
|
||||||
@@ -723,7 +843,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
if player in threshold_percentages):
|
if player in threshold_percentages):
|
||||||
break
|
break
|
||||||
elif not balancing_sphere:
|
elif not balancing_sphere:
|
||||||
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
|
||||||
# Gather a set of locations which we can swap items into
|
# Gather a set of locations which we can swap items into
|
||||||
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||||
for l in unchecked_locations:
|
for l in unchecked_locations:
|
||||||
@@ -739,8 +859,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
testing = items_to_test.pop()
|
testing = items_to_test.pop()
|
||||||
reducing_state = state.copy()
|
reducing_state = state.copy()
|
||||||
for location in itertools.chain((
|
for location in itertools.chain((
|
||||||
l for l in items_to_replace
|
l for l in items_to_replace
|
||||||
if l.item.player == player
|
if l.item.player == player
|
||||||
), items_to_test):
|
), items_to_test):
|
||||||
reducing_state.collect(location.item, True, location)
|
reducing_state.collect(location.item, True, location)
|
||||||
|
|
||||||
@@ -813,52 +933,30 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
|
|||||||
location_2.item.location = location_2
|
location_2.item.location = location_2
|
||||||
|
|
||||||
|
|
||||||
def distribute_planned(multiworld: MultiWorld) -> None:
|
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]:
|
||||||
def warn(warning: str, force: typing.Union[bool, str]) -> None:
|
def warn(warning: str, force: bool | str) -> None:
|
||||||
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
|
if isinstance(force, bool):
|
||||||
logging.warning(f'{warning}')
|
logging.warning(f"{warning}")
|
||||||
else:
|
else:
|
||||||
logging.debug(f'{warning}')
|
logging.debug(f"{warning}")
|
||||||
|
|
||||||
def failed(warning: str, force: typing.Union[bool, str]) -> None:
|
def failed(warning: str, force: bool | str) -> None:
|
||||||
if force in [True, 'fail', 'failure']:
|
if force is True:
|
||||||
raise Exception(warning)
|
raise Exception(warning)
|
||||||
else:
|
else:
|
||||||
warn(warning, force)
|
warn(warning, force)
|
||||||
|
|
||||||
swept_state = multiworld.state.copy()
|
|
||||||
swept_state.sweep_for_advancements()
|
|
||||||
reachable = frozenset(multiworld.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 multiworld.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)
|
|
||||||
|
|
||||||
world_name_lookup = multiworld.world_name_lookup
|
world_name_lookup = multiworld.world_name_lookup
|
||||||
|
|
||||||
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
|
plando_blocks: dict[int, list[PlandoItemBlock]] = dict()
|
||||||
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
|
player_ids: set[int] = set(multiworld.player_ids)
|
||||||
player_ids = set(multiworld.player_ids)
|
|
||||||
for player in player_ids:
|
for player in player_ids:
|
||||||
for block in multiworld.plando_items[player]:
|
plando_blocks[player] = []
|
||||||
block['player'] = player
|
for block in multiworld.worlds[player].options.plando_items:
|
||||||
if 'force' not in block:
|
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
|
||||||
block['force'] = 'silent'
|
target_world = block.world
|
||||||
if 'from_pool' not in block:
|
|
||||||
block['from_pool'] = True
|
|
||||||
elif not isinstance(block['from_pool'], bool):
|
|
||||||
from_pool_type = type(block['from_pool'])
|
|
||||||
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
|
|
||||||
if 'world' not in block:
|
|
||||||
target_world = False
|
|
||||||
else:
|
|
||||||
target_world = block['world']
|
|
||||||
|
|
||||||
if target_world is False or multiworld.players == 1: # target own world
|
if target_world is False or multiworld.players == 1: # target own world
|
||||||
worlds: typing.Set[int] = {player}
|
worlds: set[int] = {player}
|
||||||
elif target_world is True: # target any worlds besides own
|
elif target_world is True: # target any worlds besides own
|
||||||
worlds = set(multiworld.player_ids) - {player}
|
worlds = set(multiworld.player_ids) - {player}
|
||||||
elif target_world is None: # target all worlds
|
elif target_world is None: # target all worlds
|
||||||
@@ -867,156 +965,201 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
worlds = set()
|
worlds = set()
|
||||||
for listed_world in target_world:
|
for listed_world in target_world:
|
||||||
if listed_world not in world_name_lookup:
|
if listed_world not in world_name_lookup:
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.",
|
||||||
block['force'])
|
block.force)
|
||||||
continue
|
continue
|
||||||
worlds.add(world_name_lookup[listed_world])
|
worlds.add(world_name_lookup[listed_world])
|
||||||
elif type(target_world) == int: # target world by slot number
|
elif type(target_world) == int: # target world by slot number
|
||||||
if target_world not in range(1, multiworld.players + 1):
|
if target_world not in range(1, multiworld.players + 1):
|
||||||
failed(
|
failed(
|
||||||
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
|
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
|
||||||
block['force'])
|
block.force)
|
||||||
continue
|
continue
|
||||||
worlds = {target_world}
|
worlds = {target_world}
|
||||||
else: # target world by slot name
|
else: # target world by slot name
|
||||||
if target_world not in world_name_lookup:
|
if target_world not in world_name_lookup:
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||||
block['force'])
|
block.force)
|
||||||
continue
|
continue
|
||||||
worlds = {world_name_lookup[target_world]}
|
worlds = {world_name_lookup[target_world]}
|
||||||
block['world'] = worlds
|
new_block.worlds = worlds
|
||||||
|
|
||||||
items: block_value = []
|
items: list[str] | dict[str, typing.Any] = block.items
|
||||||
if "items" in block:
|
|
||||||
items = block["items"]
|
|
||||||
if 'count' not in block:
|
|
||||||
block['count'] = False
|
|
||||||
elif "item" in block:
|
|
||||||
items = block["item"]
|
|
||||||
if 'count' not in block:
|
|
||||||
block['count'] = 1
|
|
||||||
else:
|
|
||||||
failed("You must specify at least one item to place items with plando.", block['force'])
|
|
||||||
continue
|
|
||||||
if isinstance(items, dict):
|
if isinstance(items, dict):
|
||||||
item_list: typing.List[str] = []
|
item_list: list[str] = []
|
||||||
for key, value in items.items():
|
for key, value in items.items():
|
||||||
if value is True:
|
if value is True:
|
||||||
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
|
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
|
||||||
item_list += [key] * value
|
item_list += [key] * value
|
||||||
items = item_list
|
items = item_list
|
||||||
if isinstance(items, str):
|
new_block.items = items
|
||||||
items = [items]
|
|
||||||
block['items'] = items
|
|
||||||
|
|
||||||
locations: block_value = []
|
locations: list[str] = block.locations
|
||||||
if 'location' in block:
|
|
||||||
locations = block['location'] # just allow 'location' to keep old yamls compatible
|
|
||||||
elif 'locations' in block:
|
|
||||||
locations = block['locations']
|
|
||||||
if isinstance(locations, str):
|
if isinstance(locations, str):
|
||||||
locations = [locations]
|
locations = [locations]
|
||||||
|
|
||||||
if isinstance(locations, dict):
|
resolved_locations: list[Location] = []
|
||||||
location_list = []
|
for target_player in worlds:
|
||||||
for key, value in locations.items():
|
locations_from_groups: list[str] = []
|
||||||
location_list += [key] * value
|
world_locations = multiworld.get_unfilled_locations(target_player)
|
||||||
locations = location_list
|
for group in multiworld.worlds[target_player].location_name_groups:
|
||||||
|
if group in locations:
|
||||||
|
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
|
||||||
|
resolved_locations.extend(location for location in world_locations
|
||||||
|
if location.name in [*locations, *locations_from_groups])
|
||||||
|
new_block.locations = sorted(dict.fromkeys(locations))
|
||||||
|
new_block.resolved_locations = sorted(set(resolved_locations))
|
||||||
|
|
||||||
|
count = block.count
|
||||||
|
if not count:
|
||||||
|
count = (min(len(new_block.items), len(new_block.resolved_locations))
|
||||||
|
if new_block.resolved_locations else len(new_block.items))
|
||||||
|
if isinstance(count, int):
|
||||||
|
count = {"min": count, "max": count}
|
||||||
|
if "min" not in count:
|
||||||
|
count["min"] = 0
|
||||||
|
if "max" not in count:
|
||||||
|
count["max"] = (min(len(new_block.items), len(new_block.resolved_locations))
|
||||||
|
if new_block.resolved_locations else len(new_block.items))
|
||||||
|
|
||||||
|
|
||||||
|
new_block.count = count
|
||||||
|
plando_blocks[player].append(new_block)
|
||||||
|
|
||||||
|
return plando_blocks
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_early_locations_for_planned(multiworld: MultiWorld):
|
||||||
|
def warn(warning: str, force: bool | str) -> None:
|
||||||
|
if isinstance(force, bool):
|
||||||
|
logging.warning(f"{warning}")
|
||||||
|
else:
|
||||||
|
logging.debug(f"{warning}")
|
||||||
|
|
||||||
|
def failed(warning: str, force: bool | str) -> None:
|
||||||
|
if force is True:
|
||||||
|
raise Exception(warning)
|
||||||
|
else:
|
||||||
|
warn(warning, force)
|
||||||
|
|
||||||
|
swept_state = multiworld.state.copy()
|
||||||
|
swept_state.sweep_for_advancements()
|
||||||
|
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
||||||
|
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
||||||
|
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
||||||
|
for loc in multiworld.get_unfilled_locations():
|
||||||
|
if loc in reachable:
|
||||||
|
early_locations[loc.player].append(loc)
|
||||||
|
else: # not reachable with swept state
|
||||||
|
non_early_locations[loc.player].append(loc)
|
||||||
|
|
||||||
|
for player in multiworld.plando_item_blocks:
|
||||||
|
removed = []
|
||||||
|
for block in multiworld.plando_item_blocks[player]:
|
||||||
|
locations = block.locations
|
||||||
|
resolved_locations = block.resolved_locations
|
||||||
|
worlds = block.worlds
|
||||||
if "early_locations" in locations:
|
if "early_locations" in locations:
|
||||||
locations.remove("early_locations")
|
|
||||||
for target_player in worlds:
|
for target_player in worlds:
|
||||||
locations += early_locations[target_player]
|
resolved_locations += early_locations[target_player]
|
||||||
if "non_early_locations" in locations:
|
if "non_early_locations" in locations:
|
||||||
locations.remove("non_early_locations")
|
|
||||||
for target_player in worlds:
|
for target_player in worlds:
|
||||||
locations += non_early_locations[target_player]
|
resolved_locations += non_early_locations[target_player]
|
||||||
|
|
||||||
block['locations'] = list(dict.fromkeys(locations))
|
if block.count["max"] > len(block.items):
|
||||||
|
count = block.count["max"]
|
||||||
|
failed(f"Plando count {count} greater than items specified", block.force)
|
||||||
|
block.count["max"] = len(block.items)
|
||||||
|
if block.count["min"] > len(block.items):
|
||||||
|
block.count["min"] = len(block.items)
|
||||||
|
if block.count["max"] > len(block.resolved_locations) > 0:
|
||||||
|
count = block.count["max"]
|
||||||
|
failed(f"Plando count {count} greater than locations specified", block.force)
|
||||||
|
block.count["max"] = len(block.resolved_locations)
|
||||||
|
if block.count["min"] > len(block.resolved_locations):
|
||||||
|
block.count["min"] = len(block.resolved_locations)
|
||||||
|
block.count["target"] = multiworld.random.randint(block.count["min"],
|
||||||
|
block.count["max"])
|
||||||
|
|
||||||
if not block['count']:
|
if not block.count["target"]:
|
||||||
block['count'] = (min(len(block['items']), len(block['locations'])) if
|
removed.append(block)
|
||||||
len(block['locations']) > 0 else len(block['items']))
|
|
||||||
if isinstance(block['count'], int):
|
|
||||||
block['count'] = {'min': block['count'], 'max': block['count']}
|
|
||||||
if 'min' not in block['count']:
|
|
||||||
block['count']['min'] = 0
|
|
||||||
if 'max' not in block['count']:
|
|
||||||
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
|
|
||||||
len(block['locations']) > 0 else len(block['items']))
|
|
||||||
if block['count']['max'] > len(block['items']):
|
|
||||||
count = block['count']
|
|
||||||
failed(f"Plando count {count} greater than items specified", block['force'])
|
|
||||||
block['count'] = len(block['items'])
|
|
||||||
if block['count']['max'] > len(block['locations']) > 0:
|
|
||||||
count = block['count']
|
|
||||||
failed(f"Plando count {count} greater than locations specified", block['force'])
|
|
||||||
block['count'] = len(block['locations'])
|
|
||||||
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
|
|
||||||
|
|
||||||
if block['count']['target'] > 0:
|
for block in removed:
|
||||||
plando_blocks.append(block)
|
multiworld.plando_item_blocks[player].remove(block)
|
||||||
|
|
||||||
|
|
||||||
|
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
|
||||||
|
def warn(warning: str, force: bool | str) -> None:
|
||||||
|
if isinstance(force, bool):
|
||||||
|
logging.warning(f"{warning}")
|
||||||
|
else:
|
||||||
|
logging.debug(f"{warning}")
|
||||||
|
|
||||||
|
def failed(warning: str, force: bool | str) -> None:
|
||||||
|
if force is True:
|
||||||
|
raise Exception(warning)
|
||||||
|
else:
|
||||||
|
warn(warning, force)
|
||||||
|
|
||||||
# shuffle, but then sort blocks by number of locations minus number of items,
|
# shuffle, but then sort blocks by number of locations minus number of items,
|
||||||
# so less-flexible blocks get priority
|
# so less-flexible blocks get priority
|
||||||
multiworld.random.shuffle(plando_blocks)
|
multiworld.random.shuffle(plando_blocks)
|
||||||
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
|
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"]
|
||||||
if len(block['locations']) > 0
|
if len(block.resolved_locations) > 0
|
||||||
else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
|
else len(multiworld.get_unfilled_locations(block.player)) -
|
||||||
|
block.count["target"]))
|
||||||
for placement in plando_blocks:
|
for placement in plando_blocks:
|
||||||
player = placement['player']
|
player = placement.player
|
||||||
try:
|
try:
|
||||||
worlds = placement['world']
|
worlds = placement.worlds
|
||||||
locations = placement['locations']
|
locations = placement.resolved_locations
|
||||||
items = placement['items']
|
items = placement.items
|
||||||
maxcount = placement['count']['target']
|
maxcount = placement.count["target"]
|
||||||
from_pool = placement['from_pool']
|
from_pool = placement.from_pool
|
||||||
|
|
||||||
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
|
item_candidates = []
|
||||||
multiworld.random.shuffle(candidates)
|
if from_pool:
|
||||||
multiworld.random.shuffle(items)
|
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
|
||||||
count = 0
|
for item in multiworld.random.sample(items, maxcount):
|
||||||
err: typing.List[str] = []
|
candidate = next((i for i in instances if i.name == item), None)
|
||||||
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
|
if candidate is None:
|
||||||
for item_name in items:
|
warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
|
||||||
item = multiworld.worlds[player].create_item(item_name)
|
f"it's already missing from it", placement.force)
|
||||||
for location in reversed(candidates):
|
candidate = multiworld.worlds[player].create_item(item)
|
||||||
if (location.address is None) == (item.code is None): # either both None or both not None
|
|
||||||
if not location.item:
|
|
||||||
if location.item_rule(item):
|
|
||||||
if location.can_fill(multiworld.state, item, False):
|
|
||||||
successful_pairs.append((item, location))
|
|
||||||
candidates.remove(location)
|
|
||||||
count = count + 1
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
err.append(f"Can't place item at {location} due to fill condition not met.")
|
|
||||||
else:
|
|
||||||
err.append(f"{item_name} not allowed at {location}.")
|
|
||||||
else:
|
|
||||||
err.append(f"Cannot place {item_name} into already filled location {location}.")
|
|
||||||
else:
|
else:
|
||||||
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
|
multiworld.itempool.remove(candidate)
|
||||||
if count == maxcount:
|
instances.remove(candidate)
|
||||||
break
|
item_candidates.append(candidate)
|
||||||
if count < placement['count']['min']:
|
else:
|
||||||
m = placement['count']['min']
|
item_candidates = [multiworld.worlds[player].create_item(item)
|
||||||
failed(
|
for item in multiworld.random.sample(items, maxcount)]
|
||||||
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
|
if any(item.code is None for item in item_candidates) \
|
||||||
placement['force'])
|
and not all(item.code is None for item in item_candidates):
|
||||||
for (item, location) in successful_pairs:
|
failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
|
||||||
multiworld.push_item(location, item, collect=False)
|
f"event items and non-event items. "
|
||||||
location.locked = True
|
f"Event items: {[item for item in item_candidates if item.code is None]}, "
|
||||||
logging.debug(f"Plando placed {item} at {location}")
|
f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
|
||||||
if from_pool:
|
placement.force)
|
||||||
try:
|
continue
|
||||||
multiworld.itempool.remove(item)
|
else:
|
||||||
except ValueError:
|
is_real = item_candidates[0].code is not None
|
||||||
warn(
|
candidates = [candidate for candidate in locations if candidate.item is None
|
||||||
f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.",
|
and bool(candidate.address) == is_real]
|
||||||
placement['force'])
|
multiworld.random.shuffle(candidates)
|
||||||
|
allstate = multiworld.get_all_state(False)
|
||||||
|
mincount = placement.count["min"]
|
||||||
|
allowed_margin = len(item_candidates) - mincount
|
||||||
|
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True,
|
||||||
|
allow_partial=True, name="Plando Main Fill")
|
||||||
|
|
||||||
|
if len(item_candidates) > allowed_margin:
|
||||||
|
failed(f"Could not place {len(item_candidates)} "
|
||||||
|
f"of {mincount + allowed_margin} item(s) "
|
||||||
|
f"for {multiworld.player_name[player]}, "
|
||||||
|
f"remaining items: {item_candidates}",
|
||||||
|
placement.force)
|
||||||
|
if from_pool:
|
||||||
|
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
|
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
|
||||||
|
|||||||
197
Generate.py
197
Generate.py
@@ -10,8 +10,8 @@ import sys
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Any, Dict, Tuple, Union
|
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
|
|||||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
||||||
|
|
||||||
|
|
||||||
def mystery_argparse():
|
def mystery_argparse(argv: list[str] | None = None):
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
defaults = settings.generator
|
defaults = settings.generator
|
||||||
@@ -42,7 +42,9 @@ def mystery_argparse():
|
|||||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level')
|
||||||
|
parser.add_argument('--log_time', help="Add timestamps to STDOUT",
|
||||||
|
default=defaults.logtime, action='store_true')
|
||||||
parser.add_argument("--csv_output", action="store_true",
|
parser.add_argument("--csv_output", action="store_true",
|
||||||
help="Output rolled player options to csv (made for async multiworld).")
|
help="Output rolled player options to csv (made for async multiworld).")
|
||||||
parser.add_argument("--plando", default=defaults.plando_options,
|
parser.add_argument("--plando", default=defaults.plando_options,
|
||||||
@@ -52,12 +54,22 @@ def mystery_argparse():
|
|||||||
parser.add_argument("--skip_output", action="store_true",
|
parser.add_argument("--skip_output", action="store_true",
|
||||||
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
|
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
|
||||||
"Intended for debugging and testing purposes.")
|
"Intended for debugging and testing purposes.")
|
||||||
args = parser.parse_args()
|
parser.add_argument("--spoiler_only", action="store_true",
|
||||||
|
help="Skips generation assertion and multidata, outputting only a spoiler log. "
|
||||||
|
"Intended for debugging and testing purposes.")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.skip_output and args.spoiler_only:
|
||||||
|
parser.error("Cannot mix --skip_output and --spoiler_only")
|
||||||
|
elif args.spoiler == 0 and args.spoiler_only:
|
||||||
|
parser.error("Cannot use --spoiler_only when --spoiler=0. Use --skip_output or set --spoiler to a different value")
|
||||||
|
|
||||||
if not os.path.isabs(args.weights_file_path):
|
if not os.path.isabs(args.weights_file_path):
|
||||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||||
if not os.path.isabs(args.meta_file_path):
|
if not os.path.isabs(args.meta_file_path):
|
||||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||||
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +77,7 @@ def get_seed_name(random_source) -> str:
|
|||||||
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||||
|
|
||||||
|
|
||||||
def main(args=None) -> Tuple[argparse.Namespace, int]:
|
def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||||
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
||||||
if __name__ == "__main__" and "worlds" in sys.modules:
|
if __name__ == "__main__" and "worlds" in sys.modules:
|
||||||
raise Exception("Worlds system should not be loaded before logging init.")
|
raise Exception("Worlds system should not be loaded before logging init.")
|
||||||
@@ -75,7 +87,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
|
|
||||||
seed = get_seed(args.seed)
|
seed = get_seed(args.seed)
|
||||||
|
|
||||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
|
||||||
random.seed(seed)
|
random.seed(seed)
|
||||||
seed_name = get_seed_name(random)
|
seed_name = get_seed_name(random)
|
||||||
|
|
||||||
@@ -83,7 +95,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
logging.info("Race mode enabled. Using non-deterministic random source.")
|
logging.info("Race mode enabled. Using non-deterministic random source.")
|
||||||
random.seed() # reset to time-based random source
|
random.seed() # reset to time-based random source
|
||||||
|
|
||||||
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
weights_cache: dict[str, tuple[Any, ...]] = {}
|
||||||
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
||||||
try:
|
try:
|
||||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||||
@@ -106,6 +118,8 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
raise Exception("Cannot mix --sameoptions with --meta")
|
raise Exception("Cannot mix --sameoptions with --meta")
|
||||||
else:
|
else:
|
||||||
meta_weights = None
|
meta_weights = None
|
||||||
|
|
||||||
|
|
||||||
player_id = 1
|
player_id = 1
|
||||||
player_files = {}
|
player_files = {}
|
||||||
for file in os.scandir(args.player_files_path):
|
for file in os.scandir(args.player_files_path):
|
||||||
@@ -114,7 +128,14 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||||
path = os.path.join(args.player_files_path, fname)
|
path = os.path.join(args.player_files_path, fname)
|
||||||
try:
|
try:
|
||||||
weights_cache[fname] = read_weights_yamls(path)
|
weights_for_file = []
|
||||||
|
for doc_idx, yaml in enumerate(read_weights_yamls(path)):
|
||||||
|
if yaml is None:
|
||||||
|
logging.warning(f"Ignoring empty yaml document #{doc_idx + 1} in {fname}")
|
||||||
|
else:
|
||||||
|
weights_for_file.append(yaml)
|
||||||
|
weights_cache[fname] = tuple(weights_for_file)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
||||||
|
|
||||||
@@ -145,20 +166,12 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
f"A mix is also permitted.")
|
f"A mix is also permitted.")
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
args.outputname = seed_name
|
||||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
|
||||||
erargs.seed = seed
|
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
||||||
erargs.plando_options = args.plando
|
args.name = {}
|
||||||
erargs.spoiler = args.spoiler
|
|
||||||
erargs.race = args.race
|
|
||||||
erargs.outputname = seed_name
|
|
||||||
erargs.outputpath = args.outputpath
|
|
||||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
|
||||||
erargs.skip_output = args.skip_output
|
|
||||||
erargs.name = {}
|
|
||||||
erargs.csv_output = args.csv_output
|
|
||||||
|
|
||||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
|
||||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||||
for fname, yamls in weights_cache.items()}
|
for fname, yamls in weights_cache.items()}
|
||||||
|
|
||||||
@@ -176,6 +189,11 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
yaml[category][key] = option
|
yaml[category][key] = option
|
||||||
elif category_name not in yaml:
|
elif category_name not in yaml:
|
||||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||||
|
elif key == "triggers":
|
||||||
|
if "triggers" not in yaml[category_name]:
|
||||||
|
yaml[category_name][key] = []
|
||||||
|
for trigger in option:
|
||||||
|
yaml[category_name][key].append(trigger)
|
||||||
else:
|
else:
|
||||||
yaml[category_name][key] = option
|
yaml[category_name][key] = option
|
||||||
|
|
||||||
@@ -183,30 +201,34 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
for player in range(1, args.multi + 1):
|
for player in range(1, args.multi + 1):
|
||||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
erargs.player_options = {}
|
args.player_options = {}
|
||||||
|
|
||||||
player = 1
|
player = 1
|
||||||
while player <= args.multi:
|
while player <= args.multi:
|
||||||
path = player_path_cache[player]
|
path = player_path_cache[player]
|
||||||
if path:
|
if path:
|
||||||
try:
|
try:
|
||||||
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||||
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
||||||
for settingsObject in settings:
|
for settingsObject in settings:
|
||||||
for k, v in vars(settingsObject).items():
|
for k, v in vars(settingsObject).items():
|
||||||
if v is not None:
|
if v is not None:
|
||||||
try:
|
try:
|
||||||
getattr(erargs, k)[player] = v
|
getattr(args, k)[player] = v
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
setattr(erargs, k, {player: v})
|
setattr(args, k, {player: v})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error setting {k} to {v} for player {player}") from 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
|
# name was not specified
|
||||||
erargs.name[player] = f"Player{player}"
|
if player not in args.name:
|
||||||
elif player not in erargs.name: # if name was not specified, generate it from filename
|
if path == args.weights_file_path:
|
||||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
# weights file, so we need to make the name unique
|
||||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
args.name[player] = f"Player{player}"
|
||||||
|
else:
|
||||||
|
# use the filename
|
||||||
|
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||||
|
args.name[player] = handle_name(args.name[player], player, name_counter)
|
||||||
|
|
||||||
player += 1
|
player += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -214,13 +236,13 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
else:
|
else:
|
||||||
raise RuntimeError(f'No weights specified for player {player}')
|
raise RuntimeError(f'No weights specified for player {player}')
|
||||||
|
|
||||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
if len(set(name.lower() for name in args.name.values())) != len(args.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}")
|
||||||
|
|
||||||
return erargs, seed
|
return args, seed
|
||||||
|
|
||||||
|
|
||||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
def read_weights_yamls(path) -> tuple[Any, ...]:
|
||||||
try:
|
try:
|
||||||
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
||||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
||||||
@@ -230,7 +252,20 @@ def read_weights_yamls(path) -> Tuple[Any, ...]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Failed to read weights ({path})") from e
|
raise Exception(f"Failed to read weights ({path})") from e
|
||||||
|
|
||||||
return tuple(parse_yamls(yaml))
|
from yaml.error import MarkedYAMLError
|
||||||
|
try:
|
||||||
|
return tuple(parse_yamls(yaml))
|
||||||
|
except MarkedYAMLError as ex:
|
||||||
|
if ex.problem_mark:
|
||||||
|
lines = yaml.splitlines()
|
||||||
|
if ex.context_mark:
|
||||||
|
relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
|
||||||
|
else:
|
||||||
|
relevant_lines = lines[ex.problem_mark.line]
|
||||||
|
error_line = " " * ex.problem_mark.column + "^"
|
||||||
|
raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
|
||||||
|
f"\n{relevant_lines}\n{error_line}")
|
||||||
|
raise ex
|
||||||
|
|
||||||
|
|
||||||
def interpret_on_off(value) -> bool:
|
def interpret_on_off(value) -> bool:
|
||||||
@@ -270,33 +305,35 @@ def get_choice(option, root, value=None) -> Any:
|
|||||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||||
|
|
||||||
|
|
||||||
class SafeDict(dict):
|
class SafeFormatter(string.Formatter):
|
||||||
def __missing__(self, key):
|
def get_value(self, key, args, kwargs):
|
||||||
return '{' + key + '}'
|
if isinstance(key, int):
|
||||||
|
if key < len(args):
|
||||||
|
return args[key]
|
||||||
|
else:
|
||||||
|
return "{" + str(key) + "}"
|
||||||
|
else:
|
||||||
|
return kwargs.get(key, "{" + key + "}")
|
||||||
|
|
||||||
|
|
||||||
def handle_name(name: str, player: int, name_counter: Counter):
|
def handle_name(name: str, player: int, name_counter: Counter):
|
||||||
name_counter[name.lower()] += 1
|
name_counter[name.lower()] += 1
|
||||||
number = name_counter[name.lower()]
|
number = name_counter[name.lower()]
|
||||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
|
||||||
NUMBER=(number if number > 1 else ''),
|
new_name = SafeFormatter().vformat(new_name, (), {"number": number,
|
||||||
player=player,
|
"NUMBER": (number if number > 1 else ''),
|
||||||
PLAYER=(player if player > 1 else '')))
|
"player": player,
|
||||||
|
"PLAYER": (player if player > 1 else '')})
|
||||||
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
|
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
|
||||||
# Could cause issues for some clients that cannot handle the additional whitespace.
|
# Could cause issues for some clients that cannot handle the additional whitespace.
|
||||||
new_name = new_name.strip()[:16].strip()
|
new_name = new_name.strip()[:16].strip()
|
||||||
|
|
||||||
if new_name == "Archipelago":
|
if new_name == "Archipelago":
|
||||||
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
||||||
return new_name
|
return new_name
|
||||||
|
|
||||||
|
|
||||||
def roll_percentage(percentage: Union[int, float]) -> bool:
|
|
||||||
"""Roll a percentage chance.
|
|
||||||
percentage is expected to be in range [0, 100]"""
|
|
||||||
return random.random() < (float(percentage) / 100)
|
|
||||||
|
|
||||||
|
|
||||||
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
||||||
logging.debug(f'Applying {new_weights}')
|
logging.debug(f'Applying {new_weights}')
|
||||||
cleaned_weights = {}
|
cleaned_weights = {}
|
||||||
@@ -310,7 +347,9 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
|||||||
elif isinstance(new_value, list):
|
elif isinstance(new_value, list):
|
||||||
cleaned_value.extend(new_value)
|
cleaned_value.extend(new_value)
|
||||||
elif isinstance(new_value, dict):
|
elif isinstance(new_value, dict):
|
||||||
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
|
counter_value = Counter(cleaned_value)
|
||||||
|
counter_value.update(new_value)
|
||||||
|
cleaned_value = dict(counter_value)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
|
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
|
||||||
f" received {type(new_value).__name__}.")
|
f" received {type(new_value).__name__}.")
|
||||||
@@ -324,13 +363,18 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
|||||||
for element in new_value:
|
for element in new_value:
|
||||||
cleaned_value.remove(element)
|
cleaned_value.remove(element)
|
||||||
elif isinstance(new_value, dict):
|
elif isinstance(new_value, dict):
|
||||||
cleaned_value = dict(Counter(cleaned_value) - Counter(new_value))
|
counter_value = Counter(cleaned_value)
|
||||||
|
counter_value.subtract(new_value)
|
||||||
|
cleaned_value = dict(counter_value)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
|
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
|
||||||
f" received {type(new_value).__name__}.")
|
f" received {type(new_value).__name__}.")
|
||||||
cleaned_weights[option_name] = cleaned_value
|
cleaned_weights[option_name] = cleaned_value
|
||||||
else:
|
else:
|
||||||
cleaned_weights[option_name] = new_weights[option]
|
# Options starting with + and - may modify values in-place, and new_weights may be shared by multiple slots
|
||||||
|
# using the same .yaml, so ensure that the new value is a copy.
|
||||||
|
cleaned_value = copy.deepcopy(new_weights[option])
|
||||||
|
cleaned_weights[option_name] = cleaned_value
|
||||||
new_options = set(cleaned_weights) - set(weights)
|
new_options = set(cleaned_weights) - set(weights)
|
||||||
weights.update(cleaned_weights)
|
weights.update(cleaned_weights)
|
||||||
if new_options:
|
if new_options:
|
||||||
@@ -341,7 +385,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
|||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
|
||||||
from worlds import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
if not game:
|
if not game:
|
||||||
@@ -353,6 +397,8 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
|||||||
if options[option_key].supports_weighting:
|
if options[option_key].supports_weighting:
|
||||||
return get_choice(option_key, category_dict)
|
return get_choice(option_key, category_dict)
|
||||||
return category_dict[option_key]
|
return category_dict[option_key]
|
||||||
|
if option_key == "triggers":
|
||||||
|
return category_dict[option_key]
|
||||||
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
|
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
|
||||||
|
|
||||||
|
|
||||||
@@ -362,7 +408,7 @@ def roll_linked_options(weights: dict) -> dict:
|
|||||||
if "name" not in option_set:
|
if "name" not in option_set:
|
||||||
raise ValueError("One of your linked options does not have a name.")
|
raise ValueError("One of your linked options does not have a name.")
|
||||||
try:
|
try:
|
||||||
if roll_percentage(option_set["percentage"]):
|
if Options.roll_percentage(option_set["percentage"]):
|
||||||
logging.debug(f"Linked option {option_set['name']} triggered.")
|
logging.debug(f"Linked option {option_set['name']} triggered.")
|
||||||
new_options = option_set["options"]
|
new_options = option_set["options"]
|
||||||
for category_name, category_options in new_options.items():
|
for category_name, category_options in new_options.items():
|
||||||
@@ -395,7 +441,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
|||||||
trigger_result = get_choice("option_result", option_set)
|
trigger_result = get_choice("option_result", option_set)
|
||||||
result = get_choice(key, currently_targeted_weights)
|
result = get_choice(key, currently_targeted_weights)
|
||||||
currently_targeted_weights[key] = result
|
currently_targeted_weights[key] = result
|
||||||
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
|
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)):
|
||||||
for category_name, category_options in option_set["options"].items():
|
for category_name, category_options in option_set["options"].items():
|
||||||
currently_targeted_weights = weights
|
currently_targeted_weights = weights
|
||||||
if category_name:
|
if category_name:
|
||||||
@@ -426,12 +472,20 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
|||||||
|
|
||||||
|
|
||||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||||
|
"""
|
||||||
|
Roll options from specified weights, usually originating from a .yaml options file.
|
||||||
|
|
||||||
|
Important note:
|
||||||
|
The same weights dict is shared between all slots using the same yaml (e.g. generic weights file for filler slots).
|
||||||
|
This means it should never be modified without making a deepcopy first.
|
||||||
|
"""
|
||||||
|
|
||||||
from worlds import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
if "linked_options" in weights:
|
if "linked_options" in weights:
|
||||||
weights = roll_linked_options(weights)
|
weights = roll_linked_options(weights)
|
||||||
|
|
||||||
valid_keys = set()
|
valid_keys = {"triggers"}
|
||||||
if "triggers" in weights:
|
if "triggers" in weights:
|
||||||
weights = roll_triggers(weights, weights["triggers"], valid_keys)
|
weights = roll_triggers(weights, weights["triggers"], valid_keys)
|
||||||
|
|
||||||
@@ -446,7 +500,22 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
if required_plando_options:
|
if required_plando_options:
|
||||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||||
f"which is not enabled.")
|
f"which is not enabled.")
|
||||||
|
games = requirements.get("game", {})
|
||||||
|
for game, version in games.items():
|
||||||
|
if game not in AutoWorldRegister.world_types:
|
||||||
|
continue
|
||||||
|
if not version:
|
||||||
|
raise Exception(f"Invalid version for game {game}: {version}.")
|
||||||
|
if isinstance(version, str):
|
||||||
|
version = {"min": version}
|
||||||
|
if "min" in version and tuplize_version(version["min"]) > AutoWorldRegister.world_types[game].world_version:
|
||||||
|
raise Exception(f"Settings reports required version of world \"{game}\" is at least {version['min']}, "
|
||||||
|
f"however world is of version "
|
||||||
|
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
|
||||||
|
if "max" in version and tuplize_version(version["max"]) < AutoWorldRegister.world_types[game].world_version:
|
||||||
|
raise Exception(f"Settings reports required version of world \"{game}\" is no later than {version['max']}, "
|
||||||
|
f"however world is of version "
|
||||||
|
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
|
||||||
ret = argparse.Namespace()
|
ret = argparse.Namespace()
|
||||||
for option_key in Options.PerGameCommonOptions.type_hints:
|
for option_key in Options.PerGameCommonOptions.type_hints:
|
||||||
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
|
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
|
||||||
@@ -490,15 +559,19 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
valid_keys.add(option_key)
|
valid_keys.add(option_key)
|
||||||
for option_key in game_weights:
|
|
||||||
if option_key in {"triggers", *valid_keys}:
|
|
||||||
continue
|
|
||||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
|
|
||||||
if PlandoOptions.items in plando_options:
|
|
||||||
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
|
||||||
if ret.game == "A Link to the Past":
|
if ret.game == "A Link to the Past":
|
||||||
|
# TODO there are still more LTTP options not on the options system
|
||||||
|
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
|
||||||
roll_alttp_settings(ret, game_weights)
|
roll_alttp_settings(ret, game_weights)
|
||||||
|
|
||||||
|
# log a warning for options within a game section that aren't determined as valid
|
||||||
|
for option_key in game_weights:
|
||||||
|
if option_key in valid_keys:
|
||||||
|
continue
|
||||||
|
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers "
|
||||||
|
f"for player {ret.name}.")
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
if __name__ == '__main__':
|
|
||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
import Utils
|
|
||||||
Utils.init_logging("KH1Client", exception_logger="Client")
|
|
||||||
|
|
||||||
from worlds.kh1.Client import launch
|
|
||||||
launch()
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import ModuleUpdate
|
|
||||||
import Utils
|
|
||||||
from worlds.kh2.Client import launch
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
Utils.init_logging("KH2Client", exception_logger="Client")
|
|
||||||
launch()
|
|
||||||
2
LICENSE
2
LICENSE
@@ -1,7 +1,7 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2017 LLCoolDave
|
Copyright (c) 2017 LLCoolDave
|
||||||
Copyright (c) 2022 Berserker66
|
Copyright (c) 2025 Berserker66
|
||||||
Copyright (c) 2022 CaitSith2
|
Copyright (c) 2022 CaitSith2
|
||||||
Copyright (c) 2021 LegendaryLinux
|
Copyright (c) 2021 LegendaryLinux
|
||||||
|
|
||||||
|
|||||||
431
Launcher.py
431
Launcher.py
@@ -1,29 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
Archipelago launcher for bundled app.
|
Archipelago Launcher
|
||||||
|
|
||||||
* if run with APBP as argument, launch corresponding client.
|
* If run with a patch file as argument, launch corresponding client with the patch file as an argument.
|
||||||
* if run with executable as argument, run it passing argv[2:] as arguments
|
* If run with component name as argument, run it passing argv[2:] as arguments.
|
||||||
* if run without arguments, open launcher GUI
|
* If run without arguments or unknown arguments, open launcher GUI.
|
||||||
|
|
||||||
Scroll down to components= to add components to the launcher as well as setup.py
|
Additional components can be added to worlds.LauncherComponents.components.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import itertools
|
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
from os.path import isfile
|
from os.path import isfile
|
||||||
from shutil import which
|
from shutil import which
|
||||||
from typing import Callable, Optional, Sequence, Tuple, Union
|
from typing import Any
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
import settings
|
import settings
|
||||||
@@ -41,13 +42,17 @@ def open_host_yaml():
|
|||||||
if is_linux:
|
if is_linux:
|
||||||
exe = which('sensible-editor') or which('gedit') or \
|
exe = which('sensible-editor') or which('gedit') or \
|
||||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||||
subprocess.Popen([exe, file])
|
|
||||||
elif is_macos:
|
elif is_macos:
|
||||||
exe = which("open")
|
exe = which("open")
|
||||||
subprocess.Popen([exe, file])
|
|
||||||
else:
|
else:
|
||||||
webbrowser.open(file)
|
webbrowser.open(file)
|
||||||
|
return
|
||||||
|
|
||||||
|
env = os.environ
|
||||||
|
if "LD_LIBRARY_PATH" in env:
|
||||||
|
env = env.copy()
|
||||||
|
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||||
|
subprocess.Popen([exe, file], env=env)
|
||||||
|
|
||||||
def open_patch():
|
def open_patch():
|
||||||
suffixes = []
|
suffixes = []
|
||||||
@@ -70,12 +75,17 @@ def open_patch():
|
|||||||
launch([*exe, file], component.cli)
|
launch([*exe, file], component.cli)
|
||||||
|
|
||||||
|
|
||||||
def generate_yamls():
|
def generate_yamls(*args):
|
||||||
from Options import generate_yaml_templates
|
from Options import generate_yaml_templates
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Generate Template Options", usage="[-h] [--skip_open_folder]")
|
||||||
|
parser.add_argument("--skip_open_folder", action="store_true")
|
||||||
|
args = parser.parse_args(args)
|
||||||
|
|
||||||
target = Utils.user_path("Players", "Templates")
|
target = Utils.user_path("Players", "Templates")
|
||||||
generate_yaml_templates(target, False)
|
generate_yaml_templates(target, False)
|
||||||
open_folder(target)
|
if not args.skip_open_folder:
|
||||||
|
open_folder(target)
|
||||||
|
|
||||||
|
|
||||||
def browse_files():
|
def browse_files():
|
||||||
@@ -85,12 +95,20 @@ def browse_files():
|
|||||||
def open_folder(folder_path):
|
def open_folder(folder_path):
|
||||||
if is_linux:
|
if is_linux:
|
||||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||||
subprocess.Popen([exe, folder_path])
|
|
||||||
elif is_macos:
|
elif is_macos:
|
||||||
exe = which("open")
|
exe = which("open")
|
||||||
subprocess.Popen([exe, folder_path])
|
|
||||||
else:
|
else:
|
||||||
webbrowser.open(folder_path)
|
webbrowser.open(folder_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
if exe:
|
||||||
|
env = os.environ
|
||||||
|
if "LD_LIBRARY_PATH" in env:
|
||||||
|
env = env.copy()
|
||||||
|
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||||
|
subprocess.Popen([exe, folder_path], env=env)
|
||||||
|
else:
|
||||||
|
logging.warning(f"No file browser available to open {folder_path}")
|
||||||
|
|
||||||
|
|
||||||
def update_settings():
|
def update_settings():
|
||||||
@@ -100,96 +118,51 @@ def update_settings():
|
|||||||
|
|
||||||
components.extend([
|
components.extend([
|
||||||
# Functions
|
# Functions
|
||||||
Component("Open host.yaml", func=open_host_yaml),
|
Component("Open host.yaml", func=open_host_yaml,
|
||||||
Component("Open Patch", func=open_patch),
|
description="Open the host.yaml file to change settings for generation, games, and more."),
|
||||||
Component("Generate Template Options", func=generate_yamls),
|
Component("Open Patch", func=open_patch,
|
||||||
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
|
description="Open a patch file, downloaded from the room page or provided by the host."),
|
||||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
Component("Generate Template Options", func=generate_yamls,
|
||||||
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
description="Generate template YAMLs for currently installed games."),
|
||||||
Component("Browse Files", func=browse_files),
|
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
|
||||||
|
description="Open archipelago.gg in your browser."),
|
||||||
|
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
|
||||||
|
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
|
||||||
|
Component("Unrated/18+ Discord Server", icon="discord",
|
||||||
|
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
|
||||||
|
description="Find unrated and 18+ games in the After Dark Discord server."),
|
||||||
|
Component("Browse Files", func=browse_files,
|
||||||
|
description="Open the Archipelago installation folder in your file browser."),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
def handle_uri(path: str) -> tuple[list[Component], Component]:
|
||||||
url = urllib.parse.urlparse(path)
|
url = urllib.parse.urlparse(path)
|
||||||
queries = urllib.parse.parse_qs(url.query)
|
queries = urllib.parse.parse_qs(url.query)
|
||||||
launch_args = (path, *launch_args)
|
client_components = []
|
||||||
client_component = None
|
|
||||||
text_client_component = None
|
text_client_component = None
|
||||||
if "game" in queries:
|
game = queries["game"][0]
|
||||||
game = queries["game"][0]
|
|
||||||
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
|
|
||||||
game = "Archipelago"
|
|
||||||
for component in components:
|
for component in components:
|
||||||
if component.supports_uri and component.game_name == game:
|
if component.supports_uri and component.game_name == game:
|
||||||
client_component = component
|
client_components.append(component)
|
||||||
elif component.display_name == "Text Client":
|
elif component.display_name == "Text Client":
|
||||||
text_client_component = component
|
text_client_component = component
|
||||||
|
return client_components, text_client_component
|
||||||
from kvui import App, Button, BoxLayout, Label, Clock, Window
|
|
||||||
|
|
||||||
class Popup(App):
|
|
||||||
timer_label: Label
|
|
||||||
remaining_time: Optional[int]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.title = "Connect to Multiworld"
|
|
||||||
self.icon = r"data/icon.png"
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def build(self):
|
|
||||||
layout = BoxLayout(orientation="vertical")
|
|
||||||
|
|
||||||
if client_component is None:
|
|
||||||
self.remaining_time = 7
|
|
||||||
label_text = (f"A game client able to parse URIs was not detected for {game}.\n"
|
|
||||||
f"Launching Text Client in 7 seconds...")
|
|
||||||
self.timer_label = Label(text=label_text)
|
|
||||||
layout.add_widget(self.timer_label)
|
|
||||||
Clock.schedule_interval(self.update_label, 1)
|
|
||||||
else:
|
|
||||||
layout.add_widget(Label(text="Select client to open and connect with."))
|
|
||||||
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
|
|
||||||
|
|
||||||
text_client_button = Button(
|
|
||||||
text=text_client_component.display_name,
|
|
||||||
on_release=lambda *args: run_component(text_client_component, *launch_args)
|
|
||||||
)
|
|
||||||
button_row.add_widget(text_client_button)
|
|
||||||
|
|
||||||
game_client_button = Button(
|
|
||||||
text=client_component.display_name,
|
|
||||||
on_release=lambda *args: run_component(client_component, *launch_args)
|
|
||||||
)
|
|
||||||
button_row.add_widget(game_client_button)
|
|
||||||
|
|
||||||
layout.add_widget(button_row)
|
|
||||||
|
|
||||||
return layout
|
|
||||||
|
|
||||||
def update_label(self, dt):
|
|
||||||
if self.remaining_time > 1:
|
|
||||||
# countdown the timer and string replace the number
|
|
||||||
self.remaining_time -= 1
|
|
||||||
self.timer_label.text = self.timer_label.text.replace(
|
|
||||||
str(self.remaining_time + 1), str(self.remaining_time)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# our timer is finished so launch text client and close down
|
|
||||||
run_component(text_client_component, *launch_args)
|
|
||||||
Clock.unschedule(self.update_label)
|
|
||||||
App.get_running_app().stop()
|
|
||||||
Window.close()
|
|
||||||
|
|
||||||
def _stop(self, *largs):
|
|
||||||
# see run_gui Launcher _stop comment for details
|
|
||||||
self.root_window.close()
|
|
||||||
super()._stop(*largs)
|
|
||||||
|
|
||||||
Popup().run()
|
|
||||||
|
|
||||||
|
|
||||||
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
|
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
|
||||||
|
from kvui import ButtonsPrompt
|
||||||
|
component_options = {
|
||||||
|
component.display_name: component for component in component_list
|
||||||
|
}
|
||||||
|
popup = ButtonsPrompt("Connect to Multiworld",
|
||||||
|
"Select client to open and connect with.",
|
||||||
|
lambda component_name: run_component(component_options[component_name], *launch_args),
|
||||||
|
*component_options.keys())
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
|
||||||
|
def identify(path: None | str) -> tuple[None | str, None | Component]:
|
||||||
if path is None:
|
if path is None:
|
||||||
return None, None
|
return None, None
|
||||||
for component in components:
|
for component in components:
|
||||||
@@ -200,7 +173,7 @@ def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Comp
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
def get_exe(component: str | Component) -> Sequence[str] | None:
|
||||||
if isinstance(component, str):
|
if isinstance(component, str):
|
||||||
name = component
|
name = component
|
||||||
component = None
|
component = None
|
||||||
@@ -228,7 +201,8 @@ def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
|||||||
def launch(exe, in_terminal=False):
|
def launch(exe, in_terminal=False):
|
||||||
if in_terminal:
|
if in_terminal:
|
||||||
if is_windows:
|
if is_windows:
|
||||||
subprocess.Popen(['start', *exe], shell=True)
|
# intentionally using a window title with a space so it gets quoted and treated as a title
|
||||||
|
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
|
||||||
return
|
return
|
||||||
elif is_linux:
|
elif is_linux:
|
||||||
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
||||||
@@ -242,101 +216,194 @@ def launch(exe, in_terminal=False):
|
|||||||
subprocess.Popen(exe)
|
subprocess.Popen(exe)
|
||||||
|
|
||||||
|
|
||||||
refresh_components: Optional[Callable[[], None]] = None
|
def create_shortcut(button: Any, component: Component) -> None:
|
||||||
|
from pyshortcuts import make_shortcut
|
||||||
|
env = os.environ
|
||||||
|
if "APPIMAGE" in env:
|
||||||
|
script = env["ARGV0"]
|
||||||
|
wkdir = None # defaults to ~ on Linux
|
||||||
|
else:
|
||||||
|
script = sys.argv[0]
|
||||||
|
wkdir = Utils.local_path()
|
||||||
|
|
||||||
|
script = f"{script} \"{component.display_name}\""
|
||||||
|
make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"),
|
||||||
|
startmenu=False, terminal=False, working_dir=wkdir, noexe=Utils.is_frozen())
|
||||||
|
button.menu.dismiss()
|
||||||
|
|
||||||
|
|
||||||
def run_gui():
|
refresh_components: Callable[[], None] | None = None
|
||||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
|
|
||||||
|
|
||||||
|
def run_gui(launch_components: list[Component], args: Any) -> None:
|
||||||
|
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
|
||||||
|
from kivy.properties import ObjectProperty
|
||||||
from kivy.core.window import Window
|
from kivy.core.window import Window
|
||||||
from kivy.uix.image import AsyncImage
|
from kivy.metrics import dp
|
||||||
from kivy.uix.relativelayout import RelativeLayout
|
from kivymd.uix.button import MDIconButton, MDButton
|
||||||
|
from kivymd.uix.card import MDCard
|
||||||
|
from kivymd.uix.menu import MDDropdownMenu
|
||||||
|
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
|
||||||
|
from kivymd.uix.textfield import MDTextField
|
||||||
|
|
||||||
class Launcher(App):
|
from kivy.lang.builder import Builder
|
||||||
|
|
||||||
|
class LauncherCard(MDCard):
|
||||||
|
component: Component | None
|
||||||
|
image: str
|
||||||
|
context_button: MDIconButton = ObjectProperty(None)
|
||||||
|
|
||||||
|
def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
|
||||||
|
self.component = component
|
||||||
|
self.image = image_path
|
||||||
|
super().__init__(args, kwargs)
|
||||||
|
|
||||||
|
class Launcher(ThemedApp):
|
||||||
base_title: str = "Archipelago Launcher"
|
base_title: str = "Archipelago Launcher"
|
||||||
container: ContainerLayout
|
top_screen: MDFloatLayout = ObjectProperty(None)
|
||||||
grid: GridLayout
|
navigation: MDGridLayout = ObjectProperty(None)
|
||||||
_tool_layout: Optional[ScrollBox] = None
|
grid: MDGridLayout = ObjectProperty(None)
|
||||||
_client_layout: Optional[ScrollBox] = None
|
button_layout: ScrollBox = ObjectProperty(None)
|
||||||
|
search_box: MDTextField = ObjectProperty(None)
|
||||||
|
cards: list[LauncherCard]
|
||||||
|
current_filter: Sequence[str | Type] | None
|
||||||
|
|
||||||
def __init__(self, ctx=None):
|
def __init__(self, ctx=None, components=None, args=None):
|
||||||
self.title = self.base_title + " " + Utils.__version__
|
self.title = self.base_title + " " + Utils.__version__
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
self.icon = r"data/icon.png"
|
self.icon = r"data/icon.png"
|
||||||
|
self.favorites = []
|
||||||
|
self.launch_components = components
|
||||||
|
self.launch_args = args
|
||||||
|
self.cards = []
|
||||||
|
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
|
||||||
|
persistent = Utils.persistent_load()
|
||||||
|
if "launcher" in persistent:
|
||||||
|
if "favorites" in persistent["launcher"]:
|
||||||
|
self.favorites.extend(persistent["launcher"]["favorites"])
|
||||||
|
if "filter" in persistent["launcher"]:
|
||||||
|
if persistent["launcher"]["filter"]:
|
||||||
|
filters = []
|
||||||
|
for filter in persistent["launcher"]["filter"].split(", "):
|
||||||
|
if filter == "favorites":
|
||||||
|
filters.append(filter)
|
||||||
|
else:
|
||||||
|
filters.append(Type[filter])
|
||||||
|
self.current_filter = filters
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def _refresh_components(self) -> None:
|
def set_favorite(self, caller):
|
||||||
|
if caller.component.display_name in self.favorites:
|
||||||
|
self.favorites.remove(caller.component.display_name)
|
||||||
|
caller.icon = "star-outline"
|
||||||
|
else:
|
||||||
|
self.favorites.append(caller.component.display_name)
|
||||||
|
caller.icon = "star"
|
||||||
|
|
||||||
def build_button(component: Component) -> Widget:
|
def build_card(self, component: Component) -> LauncherCard:
|
||||||
|
"""
|
||||||
|
Builds a card widget for a given component.
|
||||||
|
|
||||||
|
:param component: The component associated with the button.
|
||||||
|
|
||||||
|
:return: The created Card Widget.
|
||||||
"""
|
"""
|
||||||
Builds a button widget for a given component.
|
button_card = LauncherCard(component=component,
|
||||||
|
image_path=icon_paths[component.icon])
|
||||||
|
|
||||||
Args:
|
def open_menu(caller):
|
||||||
component (Component): The component associated with the button.
|
caller.menu.open()
|
||||||
|
|
||||||
Returns:
|
menu_items = [
|
||||||
None. The button is added to the parent grid layout.
|
{
|
||||||
|
"text": "Add shortcut on desktop",
|
||||||
|
"leading_icon": "laptop",
|
||||||
|
"on_release": lambda: create_shortcut(button_card.context_button, component)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items)
|
||||||
|
button_card.context_button.bind(on_release=open_menu)
|
||||||
|
|
||||||
"""
|
return button_card
|
||||||
button = Button(text=component.display_name, size_hint_y=None, height=40)
|
|
||||||
button.component = component
|
def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
|
||||||
button.bind(on_release=self.component_action)
|
if not type_filter:
|
||||||
if component.icon != "icon":
|
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
|
||||||
image = AsyncImage(source=icon_paths[component.icon],
|
favorites = "favorites" in type_filter
|
||||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
|
||||||
box_layout = RelativeLayout(size_hint_y=None, height=40)
|
|
||||||
box_layout.add_widget(button)
|
|
||||||
box_layout.add_widget(image)
|
|
||||||
return box_layout
|
|
||||||
return button
|
|
||||||
|
|
||||||
# clear before repopulating
|
# clear before repopulating
|
||||||
assert self._tool_layout and self._client_layout, "must call `build` first"
|
assert self.button_layout, "must call `build` first"
|
||||||
tool_children = reversed(self._tool_layout.layout.children)
|
tool_children = reversed(self.button_layout.layout.children)
|
||||||
for child in tool_children:
|
for child in tool_children:
|
||||||
self._tool_layout.layout.remove_widget(child)
|
self.button_layout.layout.remove_widget(child)
|
||||||
client_children = reversed(self._client_layout.layout.children)
|
|
||||||
for child in client_children:
|
|
||||||
self._client_layout.layout.remove_widget(child)
|
|
||||||
|
|
||||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
cards = [card for card in self.cards if card.component.type in type_filter
|
||||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
or favorites and card.component.display_name in self.favorites]
|
||||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
|
||||||
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
|
||||||
|
|
||||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
self.current_filter = type_filter
|
||||||
_tools.items(), _miscs.items(), _adjusters.items()
|
|
||||||
), _clients.items()):
|
for card in cards:
|
||||||
# column 1
|
self.button_layout.layout.add_widget(card)
|
||||||
if tool:
|
|
||||||
self._tool_layout.layout.add_widget(build_button(tool[1]))
|
top = self.button_layout.children[0].y + self.button_layout.children[0].height \
|
||||||
# column 2
|
- self.button_layout.height
|
||||||
if client:
|
scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
|
||||||
self._client_layout.layout.add_widget(build_button(client[1]))
|
self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
|
||||||
|
|
||||||
|
def filter_clients_by_type(self, caller: MDButton):
|
||||||
|
self._refresh_components(caller.type)
|
||||||
|
self.search_box.text = ""
|
||||||
|
|
||||||
|
def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
|
||||||
|
if len(name) == 0:
|
||||||
|
self._refresh_components(self.current_filter)
|
||||||
|
return
|
||||||
|
|
||||||
|
sub_matches = [
|
||||||
|
card for card in self.cards
|
||||||
|
if name.lower() in card.component.display_name.lower() and card.component.type != Type.HIDDEN
|
||||||
|
]
|
||||||
|
self.button_layout.layout.clear_widgets()
|
||||||
|
for card in sub_matches:
|
||||||
|
self.button_layout.layout.add_widget(card)
|
||||||
|
|
||||||
def build(self):
|
def build(self):
|
||||||
self.container = ContainerLayout()
|
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
|
||||||
self.grid = GridLayout(cols=2)
|
self.grid = self.top_screen.ids.grid
|
||||||
self.container.add_widget(self.grid)
|
self.navigation = self.top_screen.ids.navigation
|
||||||
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
|
self.button_layout = self.top_screen.ids.button_layout
|
||||||
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
self.search_box = self.top_screen.ids.search_box
|
||||||
self._tool_layout = ScrollBox()
|
self.set_colors()
|
||||||
self._tool_layout.layout.orientation = "vertical"
|
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
|
||||||
self.grid.add_widget(self._tool_layout)
|
|
||||||
self._client_layout = ScrollBox()
|
|
||||||
self._client_layout.layout.orientation = "vertical"
|
|
||||||
self.grid.add_widget(self._client_layout)
|
|
||||||
|
|
||||||
self._refresh_components()
|
|
||||||
|
|
||||||
global refresh_components
|
global refresh_components
|
||||||
refresh_components = self._refresh_components
|
refresh_components = self._refresh_components
|
||||||
|
|
||||||
Window.bind(on_drop_file=self._on_drop_file)
|
Window.bind(on_drop_file=self._on_drop_file)
|
||||||
|
Window.bind(on_keyboard=self._on_keyboard)
|
||||||
|
|
||||||
return self.container
|
for component in components:
|
||||||
|
self.cards.append(self.build_card(component))
|
||||||
|
|
||||||
|
self._refresh_components(self.current_filter)
|
||||||
|
|
||||||
|
# Uncomment to re-enable the Kivy console/live editor
|
||||||
|
# Ctrl-E to enable it, make sure numlock/capslock is disabled
|
||||||
|
# from kivy.modules.console import create_console
|
||||||
|
# create_console(Window, self.top_screen)
|
||||||
|
|
||||||
|
return self.top_screen
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
if self.launch_components:
|
||||||
|
build_uri_popup(self.launch_components, self.launch_args)
|
||||||
|
self.launch_components = None
|
||||||
|
self.launch_args = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def component_action(button):
|
def component_action(button):
|
||||||
|
MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
|
||||||
|
size_hint_x=0.5).open()
|
||||||
if button.component.func:
|
if button.component.func:
|
||||||
button.component.func()
|
button.component.func()
|
||||||
else:
|
else:
|
||||||
@@ -348,7 +415,16 @@ def run_gui():
|
|||||||
if file and component:
|
if file and component:
|
||||||
run_component(component, file)
|
run_component(component, file)
|
||||||
else:
|
else:
|
||||||
logging.warning(f"unable to identify component for {file}")
|
logging.warning(f"unable to identify component for {filename}")
|
||||||
|
|
||||||
|
def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
|
||||||
|
# Activate search as soon as we start typing, no matter if we are focused on the search box or not.
|
||||||
|
# Focus first, then capture the first character we type, otherwise it gets swallowed and lost.
|
||||||
|
# Limit text input to ASCII non-control characters (space bar to tilde).
|
||||||
|
if not self.search_box.focus:
|
||||||
|
self.search_box.focus = True
|
||||||
|
if key in range(32, 126):
|
||||||
|
self.search_box.text += codepoint
|
||||||
|
|
||||||
def _stop(self, *largs):
|
def _stop(self, *largs):
|
||||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||||
@@ -356,7 +432,13 @@ def run_gui():
|
|||||||
self.root_window.close()
|
self.root_window.close()
|
||||||
super()._stop(*largs)
|
super()._stop(*largs)
|
||||||
|
|
||||||
Launcher().run()
|
def on_stop(self):
|
||||||
|
Utils.persistent_store("launcher", "favorites", self.favorites)
|
||||||
|
Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
|
||||||
|
for filter in self.current_filter))
|
||||||
|
super().on_stop()
|
||||||
|
|
||||||
|
Launcher(components=launch_components, args=args).run()
|
||||||
|
|
||||||
# avoiding Launcher reference leak
|
# avoiding Launcher reference leak
|
||||||
# and don't try to do something with widgets after window closed
|
# and don't try to do something with widgets after window closed
|
||||||
@@ -375,7 +457,7 @@ def run_component(component: Component, *args):
|
|||||||
logging.warning(f"Component {component} does not appear to be executable.")
|
logging.warning(f"Component {component} does not appear to be executable.")
|
||||||
|
|
||||||
|
|
||||||
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
def main(args: argparse.Namespace | dict | None = None):
|
||||||
if isinstance(args, argparse.Namespace):
|
if isinstance(args, argparse.Namespace):
|
||||||
args = {k: v for k, v in args._get_kwargs()}
|
args = {k: v for k, v in args._get_kwargs()}
|
||||||
elif not args:
|
elif not args:
|
||||||
@@ -384,15 +466,21 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
|||||||
path = args.get("Patch|Game|Component|url", None)
|
path = args.get("Patch|Game|Component|url", None)
|
||||||
if path is not None:
|
if path is not None:
|
||||||
if path.startswith("archipelago://"):
|
if path.startswith("archipelago://"):
|
||||||
handle_uri(path, args.get("args", ()))
|
args["args"] = (path, *args.get("args", ()))
|
||||||
return
|
# add the url arg to the passthrough args
|
||||||
file, component = identify(path)
|
components, text_client_component = handle_uri(path)
|
||||||
if file:
|
if not components:
|
||||||
args['file'] = file
|
args["component"] = text_client_component
|
||||||
if component:
|
else:
|
||||||
args['component'] = component
|
args['launch_components'] = [text_client_component, *components]
|
||||||
if not component:
|
else:
|
||||||
logging.warning(f"Could not identify Component responsible for {path}")
|
file, component = identify(path)
|
||||||
|
if file:
|
||||||
|
args['file'] = file
|
||||||
|
if component:
|
||||||
|
args['component'] = component
|
||||||
|
if not component:
|
||||||
|
logging.warning(f"Could not identify Component responsible for {path}")
|
||||||
|
|
||||||
if args["update_settings"]:
|
if args["update_settings"]:
|
||||||
update_settings()
|
update_settings()
|
||||||
@@ -401,12 +489,12 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
|||||||
elif "component" in args:
|
elif "component" in args:
|
||||||
run_component(args["component"], *args["args"])
|
run_component(args["component"], *args["args"])
|
||||||
elif not args["update_settings"]:
|
elif not args["update_settings"]:
|
||||||
run_gui()
|
run_gui(args.get("launch_components", None), args.get("args", ()))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
init_logging('Launcher')
|
init_logging('Launcher')
|
||||||
Utils.freeze_support()
|
multiprocessing.freeze_support()
|
||||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description='Archipelago Launcher',
|
description='Archipelago Launcher',
|
||||||
@@ -423,6 +511,7 @@ if __name__ == '__main__':
|
|||||||
main(parser.parse_args())
|
main(parser.parse_args())
|
||||||
|
|
||||||
from worlds.LauncherComponents import processes
|
from worlds.LauncherComponents import processes
|
||||||
|
|
||||||
for process in processes:
|
for process in processes:
|
||||||
# we await all child processes to close before we tear down the process host
|
# we await all child processes to close before we tear down the process host
|
||||||
# this makes it feel like each one is its own program, as the Launcher is closed now
|
# this makes it feel like each one is its own program, as the Launcher is closed now
|
||||||
|
|||||||
@@ -32,11 +32,16 @@ GAME_ALTTP = "A Link to the Past"
|
|||||||
WINDOW_MIN_HEIGHT = 525
|
WINDOW_MIN_HEIGHT = 525
|
||||||
WINDOW_MIN_WIDTH = 425
|
WINDOW_MIN_WIDTH = 425
|
||||||
|
|
||||||
|
|
||||||
class AdjusterWorld(object):
|
class AdjusterWorld(object):
|
||||||
|
class AdjusterSubWorld(object):
|
||||||
|
def __init__(self, random):
|
||||||
|
self.random = random
|
||||||
|
|
||||||
def __init__(self, sprite_pool):
|
def __init__(self, sprite_pool):
|
||||||
import random
|
import random
|
||||||
self.sprite_pool = {1: sprite_pool}
|
self.sprite_pool = {1: sprite_pool}
|
||||||
self.per_slot_randoms = {1: random}
|
self.worlds = {1: self.AdjusterSubWorld(random)}
|
||||||
|
|
||||||
|
|
||||||
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||||
@@ -44,6 +49,7 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
|||||||
def _get_help_string(self, action):
|
def _get_help_string(self, action):
|
||||||
return textwrap.dedent(action.help)
|
return textwrap.dedent(action.help)
|
||||||
|
|
||||||
|
|
||||||
# See argparse.BooleanOptionalAction
|
# See argparse.BooleanOptionalAction
|
||||||
class BooleanOptionalActionWithDisable(argparse.Action):
|
class BooleanOptionalActionWithDisable(argparse.Action):
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
@@ -359,10 +365,10 @@ def run_sprite_update():
|
|||||||
logging.info("Done updating sprites")
|
logging.info("Done updating sprites")
|
||||||
|
|
||||||
|
|
||||||
def update_sprites(task, on_finish=None):
|
def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"):
|
||||||
resultmessage = ""
|
resultmessage = ""
|
||||||
successful = True
|
successful = True
|
||||||
sprite_dir = user_path("data", "sprites", "alttpr")
|
sprite_dir = user_path("data", "sprites", "alttp", "remote")
|
||||||
os.makedirs(sprite_dir, exist_ok=True)
|
os.makedirs(sprite_dir, exist_ok=True)
|
||||||
ctx = get_cert_none_ssl_context()
|
ctx = get_cert_none_ssl_context()
|
||||||
|
|
||||||
@@ -372,11 +378,11 @@ def update_sprites(task, on_finish=None):
|
|||||||
on_finish(successful, resultmessage)
|
on_finish(successful, resultmessage)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
task.update_status("Downloading alttpr sprites list")
|
task.update_status("Downloading remote sprites list")
|
||||||
with urlopen('https://alttpr.com/sprites', context=ctx) as response:
|
with urlopen(repository_url, context=ctx) as response:
|
||||||
sprites_arr = json.loads(response.read().decode("utf-8"))
|
sprites_arr = json.loads(response.read().decode("utf-8"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
||||||
successful = False
|
successful = False
|
||||||
task.queue_event(finished)
|
task.queue_event(finished)
|
||||||
return
|
return
|
||||||
@@ -384,13 +390,13 @@ def update_sprites(task, on_finish=None):
|
|||||||
try:
|
try:
|
||||||
task.update_status("Determining needed sprites")
|
task.update_status("Determining needed sprites")
|
||||||
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
|
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
|
||||||
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
||||||
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
|
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
|
||||||
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
|
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if
|
||||||
filename not in current_sprites]
|
filename not in current_sprites]
|
||||||
|
|
||||||
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
|
remote_filenames = [filename for (_, filename) in remote_sprites]
|
||||||
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
|
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
|
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
|
||||||
type(e).__name__, e)
|
type(e).__name__, e)
|
||||||
@@ -442,7 +448,7 @@ def update_sprites(task, on_finish=None):
|
|||||||
successful = False
|
successful = False
|
||||||
|
|
||||||
if successful:
|
if successful:
|
||||||
resultmessage = "alttpr sprites updated successfully"
|
resultmessage = "Remote sprites updated successfully"
|
||||||
|
|
||||||
task.queue_event(finished)
|
task.queue_event(finished)
|
||||||
|
|
||||||
@@ -863,7 +869,7 @@ class SpriteSelector():
|
|||||||
def open_custom_sprite_dir(_evt):
|
def open_custom_sprite_dir(_evt):
|
||||||
open_file(self.custom_sprite_dir)
|
open_file(self.custom_sprite_dir)
|
||||||
|
|
||||||
alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
|
remote_frametitle = Label(self.window, text='Remote Sprites')
|
||||||
|
|
||||||
custom_frametitle = Frame(self.window)
|
custom_frametitle = Frame(self.window)
|
||||||
title_text = Label(custom_frametitle, text="Custom Sprites")
|
title_text = Label(custom_frametitle, text="Custom Sprites")
|
||||||
@@ -872,8 +878,8 @@ class SpriteSelector():
|
|||||||
title_link.pack(side=LEFT)
|
title_link.pack(side=LEFT)
|
||||||
title_link.bind("<Button-1>", open_custom_sprite_dir)
|
title_link.bind("<Button-1>", open_custom_sprite_dir)
|
||||||
|
|
||||||
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
|
self.icon_section(remote_frametitle, self.remote_sprite_dir,
|
||||||
'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
|
'Remote sprites not found. Click "Update remote sprites" to download them.')
|
||||||
self.icon_section(custom_frametitle, self.custom_sprite_dir,
|
self.icon_section(custom_frametitle, self.custom_sprite_dir,
|
||||||
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
|
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
|
||||||
if not randomOnEvent:
|
if not randomOnEvent:
|
||||||
@@ -886,11 +892,18 @@ class SpriteSelector():
|
|||||||
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
|
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
|
||||||
button.pack(side=RIGHT, padx=(5, 0))
|
button.pack(side=RIGHT, padx=(5, 0))
|
||||||
|
|
||||||
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
|
button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites)
|
||||||
button.pack(side=RIGHT, padx=(5, 0))
|
button.pack(side=RIGHT, padx=(5, 0))
|
||||||
|
|
||||||
|
repository_label = Label(frame, text='Sprite Repository:')
|
||||||
|
self.repository_url = StringVar(frame, "https://alttpr.com/sprites")
|
||||||
|
repository_entry = Entry(frame, textvariable=self.repository_url)
|
||||||
|
|
||||||
|
repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1)
|
||||||
|
repository_label.pack(side=RIGHT, expand=False, padx=(0, 5))
|
||||||
|
|
||||||
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
|
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
|
||||||
button.pack(side=LEFT,padx=(0,5))
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
|
|
||||||
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
|
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
|
||||||
button.pack(side=LEFT, padx=(0, 5))
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
@@ -1050,7 +1063,7 @@ class SpriteSelector():
|
|||||||
for i, button in enumerate(frame.buttons):
|
for i, button in enumerate(frame.buttons):
|
||||||
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
|
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
|
||||||
|
|
||||||
def update_alttpr_sprites(self):
|
def update_remote_sprites(self):
|
||||||
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
|
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
|
||||||
self.window.destroy()
|
self.window.destroy()
|
||||||
self.parent.update()
|
self.parent.update()
|
||||||
@@ -1063,7 +1076,8 @@ class SpriteSelector():
|
|||||||
messagebox.showerror("Sprite Updater", resultmessage)
|
messagebox.showerror("Sprite Updater", resultmessage)
|
||||||
SpriteSelector(self.parent, self.callback, self.adjuster)
|
SpriteSelector(self.parent, self.callback, self.adjuster)
|
||||||
|
|
||||||
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
|
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites",
|
||||||
|
on_finish, self.repository_url.get())
|
||||||
|
|
||||||
def browse_for_sprite(self):
|
def browse_for_sprite(self):
|
||||||
sprite = filedialog.askopenfilename(
|
sprite = filedialog.askopenfilename(
|
||||||
@@ -1153,12 +1167,13 @@ class SpriteSelector():
|
|||||||
os.makedirs(self.custom_sprite_dir)
|
os.makedirs(self.custom_sprite_dir)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def alttpr_sprite_dir(self):
|
def remote_sprite_dir(self):
|
||||||
return user_path("data", "sprites", "alttpr")
|
return user_path("data", "sprites", "alttp", "remote")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def custom_sprite_dir(self):
|
def custom_sprite_dir(self):
|
||||||
return user_path("data", "sprites", "custom")
|
return user_path("data", "sprites", "alttp", "custom")
|
||||||
|
|
||||||
|
|
||||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||||
if not sprite.valid:
|
if not sprite.valid:
|
||||||
|
|||||||
@@ -286,16 +286,14 @@ async def gba_sync_task(ctx: MMBN3Context):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
options = Utils.get_options().get("mmbn3_options", None)
|
from worlds.mmbn3 import MMBN3World
|
||||||
if options is None:
|
auto_start = MMBN3World.settings.rom_start
|
||||||
auto_start = True
|
if auto_start is True:
|
||||||
else:
|
|
||||||
auto_start = options.get("rom_start", True)
|
|
||||||
if auto_start:
|
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
elif os.path.isfile(auto_start):
|
elif os.path.isfile(auto_start):
|
||||||
@@ -370,7 +368,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
218
Main.py
218
Main.py
@@ -1,20 +1,21 @@
|
|||||||
import collections
|
import collections
|
||||||
|
from collections.abc import Mapping
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pickle
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
from typing import Any
|
||||||
import zipfile
|
import zipfile
|
||||||
import zlib
|
import zlib
|
||||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
|
||||||
|
|
||||||
import worlds
|
import worlds
|
||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
||||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
|
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
|
||||||
flood_items
|
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
|
||||||
|
from NetUtils import convert_to_base_types
|
||||||
from Options import StartInventoryPool
|
from Options import StartInventoryPool
|
||||||
from Utils import __version__, output_path, version_tuple, get_settings
|
from Utils import __version__, output_path, restricted_dumps, version_tuple
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||||
@@ -22,7 +23,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
|
|||||||
__all__ = ["main"]
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
|
def main(args, seed=None, baked_server_options: dict[str, object] | None = None):
|
||||||
if not baked_server_options:
|
if not baked_server_options:
|
||||||
baked_server_options = get_settings().server_options.as_dict()
|
baked_server_options = get_settings().server_options.as_dict()
|
||||||
assert isinstance(baked_server_options, dict)
|
assert isinstance(baked_server_options, dict)
|
||||||
@@ -36,10 +37,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
||||||
multiworld.plando_options = args.plando_options
|
multiworld.plando_options = args.plando
|
||||||
multiworld.plando_items = args.plando_items.copy()
|
|
||||||
multiworld.plando_texts = args.plando_texts.copy()
|
|
||||||
multiworld.plando_connections = args.plando_connections.copy()
|
|
||||||
multiworld.game = args.game.copy()
|
multiworld.game = args.game.copy()
|
||||||
multiworld.player_name = args.name.copy()
|
multiworld.player_name = args.name.copy()
|
||||||
multiworld.sprite = args.sprite.copy()
|
multiworld.sprite = args.sprite.copy()
|
||||||
@@ -56,32 +54,23 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
||||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||||
|
|
||||||
max_item = 0
|
world_classes = AutoWorld.AutoWorldRegister.world_types.values()
|
||||||
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))
|
version_count = max(len(cls.world_version.as_simple_string()) for cls in world_classes)
|
||||||
location_digits = len(str(max_location))
|
item_count = len(str(max(len(cls.item_names) for cls in world_classes)))
|
||||||
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 world_classes)))
|
||||||
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
|
||||||
del max_item, max_location
|
|
||||||
|
|
||||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||||
if not cls.hidden and len(cls.item_names) > 0:
|
if not cls.hidden and len(cls.item_names) > 0:
|
||||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
|
logger.info(f" {name:{longest_name}}: "
|
||||||
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
|
f"v{cls.world_version.as_simple_string():{version_count}} | "
|
||||||
f"{max(cls.item_id_to_name):{item_digits}}) | "
|
f"Items: {len(cls.item_names):{item_count}} | "
|
||||||
f"{len(cls.location_names):{location_count}} "
|
f"Locations: {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
|
del item_count, location_count
|
||||||
|
|
||||||
# This assertion method should not be necessary to run if we are not outputting any multidata.
|
# This assertion method should not be necessary to run if we are not outputting any multidata.
|
||||||
if not args.skip_output:
|
if not args.skip_output and not args.spoiler_only:
|
||||||
AutoWorld.call_stage(multiworld, "assert_generate")
|
AutoWorld.call_stage(multiworld, "assert_generate")
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "generate_early")
|
AutoWorld.call_all(multiworld, "generate_early")
|
||||||
@@ -110,6 +99,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
del local_early
|
del local_early
|
||||||
del early
|
del early
|
||||||
|
|
||||||
|
# items can't be both local and non-local, prefer local
|
||||||
|
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
|
||||||
|
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
|
||||||
|
|
||||||
|
# Clear non-applicable local and non-local items.
|
||||||
|
if multiworld.players == 1:
|
||||||
|
multiworld.worlds[1].options.non_local_items.value = set()
|
||||||
|
multiworld.worlds[1].options.local_items.value = set()
|
||||||
|
|
||||||
logger.info('Creating MultiWorld.')
|
logger.info('Creating MultiWorld.')
|
||||||
AutoWorld.call_all(multiworld, "create_regions")
|
AutoWorld.call_all(multiworld, "create_regions")
|
||||||
|
|
||||||
@@ -117,12 +115,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
AutoWorld.call_all(multiworld, "create_items")
|
AutoWorld.call_all(multiworld, "create_items")
|
||||||
|
|
||||||
logger.info('Calculating Access Rules.')
|
logger.info('Calculating Access Rules.')
|
||||||
|
|
||||||
for player in multiworld.player_ids:
|
|
||||||
# items can't be both local and non-local, prefer local
|
|
||||||
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
|
|
||||||
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
|
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "set_rules")
|
AutoWorld.call_all(multiworld, "set_rules")
|
||||||
|
|
||||||
for player in multiworld.player_ids:
|
for player in multiworld.player_ids:
|
||||||
@@ -143,64 +135,59 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
|
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
|
||||||
|
|
||||||
# Set local and non-local item rules.
|
# Set local and non-local item rules.
|
||||||
|
# This function is called so late because worlds might otherwise overwrite item_rules which are how locality works
|
||||||
if multiworld.players > 1:
|
if multiworld.players > 1:
|
||||||
locality_rules(multiworld)
|
locality_rules(multiworld)
|
||||||
else:
|
|
||||||
multiworld.worlds[1].options.non_local_items.value = set()
|
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
|
||||||
multiworld.worlds[1].options.local_items.value = set()
|
|
||||||
|
AutoWorld.call_all(multiworld, "connect_entrances")
|
||||||
AutoWorld.call_all(multiworld, "generate_basic")
|
AutoWorld.call_all(multiworld, "generate_basic")
|
||||||
|
|
||||||
# remove starting inventory from pool items.
|
# remove starting inventory from pool items.
|
||||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||||
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
|
fallback_inventory = StartInventoryPool({})
|
||||||
new_items: List[Item] = []
|
depletion_pool: dict[int, dict[str, int]] = {
|
||||||
old_items: List[Item] = []
|
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
||||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
for player in multiworld.player_ids
|
||||||
player: getattr(multiworld.worlds[player].options,
|
}
|
||||||
"start_inventory_from_pool",
|
target_per_player = {
|
||||||
StartInventoryPool({})).value.copy()
|
player: sum(target_items.values()) for player, target_items in depletion_pool.items() if target_items
|
||||||
for player in multiworld.player_ids
|
}
|
||||||
}
|
|
||||||
for player, items in depletion_pool.items():
|
|
||||||
player_world: AutoWorld.World = multiworld.worlds[player]
|
|
||||||
for count in items.values():
|
|
||||||
for _ in range(count):
|
|
||||||
new_items.append(player_world.create_filler())
|
|
||||||
target: int = sum(sum(items.values()) for items in depletion_pool.values())
|
|
||||||
for i, item in enumerate(multiworld.itempool):
|
|
||||||
if depletion_pool[item.player].get(item.name, 0):
|
|
||||||
target -= 1
|
|
||||||
depletion_pool[item.player][item.name] -= 1
|
|
||||||
# quick abort if we have found all items
|
|
||||||
if not target:
|
|
||||||
old_items.extend(multiworld.itempool[i+1:])
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
old_items.append(item)
|
|
||||||
|
|
||||||
# leftovers?
|
if target_per_player:
|
||||||
if target:
|
new_itempool: list[Item] = []
|
||||||
for player, remaining_items in depletion_pool.items():
|
|
||||||
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
# Make new itempool with start_inventory_from_pool items removed
|
||||||
if remaining_items:
|
for item in multiworld.itempool:
|
||||||
logger.warning(f"{multiworld.get_player_name(player)}"
|
if depletion_pool[item.player].get(item.name, 0):
|
||||||
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
depletion_pool[item.player][item.name] -= 1
|
||||||
# find all filler we generated for the current player and remove until it matches
|
else:
|
||||||
removables = [item for item in new_items if item.player == player]
|
new_itempool.append(item)
|
||||||
for _ in range(sum(remaining_items.values())):
|
|
||||||
new_items.remove(removables.pop())
|
# Create filler in place of the removed items, warn if any items couldn't be found in the multiworld itempool
|
||||||
assert len(multiworld.itempool) == len(new_items + old_items), "Item Pool amounts should not change."
|
for player, target in target_per_player.items():
|
||||||
multiworld.itempool[:] = new_items + old_items
|
unfound_items = {item: count for item, count in depletion_pool[player].items() if count}
|
||||||
|
|
||||||
|
if unfound_items:
|
||||||
|
player_name = multiworld.get_player_name(player)
|
||||||
|
logger.warning(f"{player_name} tried to remove items from their pool that don't exist: {unfound_items}")
|
||||||
|
|
||||||
|
needed_items = target_per_player[player] - sum(unfound_items.values())
|
||||||
|
new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)]
|
||||||
|
|
||||||
|
assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change."
|
||||||
|
multiworld.itempool[:] = new_itempool
|
||||||
|
|
||||||
multiworld.link_items()
|
multiworld.link_items()
|
||||||
|
|
||||||
if any(multiworld.item_links.values()):
|
if any(world.options.item_links for world in multiworld.worlds.values()):
|
||||||
multiworld._all_state = None
|
multiworld._all_state = None
|
||||||
|
|
||||||
logger.info("Running Item Plando.")
|
logger.info("Running Item Plando.")
|
||||||
|
resolve_early_locations_for_planned(multiworld)
|
||||||
distribute_planned(multiworld)
|
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks
|
||||||
|
for x in multiworld.plando_item_blocks[player]])
|
||||||
|
|
||||||
logger.info('Running Pre Main Fill.')
|
logger.info('Running Pre Main Fill.')
|
||||||
|
|
||||||
@@ -230,6 +217,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
logger.info(f'Beginning output...')
|
logger.info(f'Beginning output...')
|
||||||
outfilebase = 'AP_' + multiworld.seed_name
|
outfilebase = 'AP_' + multiworld.seed_name
|
||||||
|
|
||||||
|
if args.spoiler_only:
|
||||||
|
if args.spoiler > 1:
|
||||||
|
logger.info('Calculating playthrough.')
|
||||||
|
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
||||||
|
|
||||||
|
multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
|
||||||
|
logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start)
|
||||||
|
return multiworld
|
||||||
|
|
||||||
output = tempfile.TemporaryDirectory()
|
output = tempfile.TemporaryDirectory()
|
||||||
with output as temp_dir:
|
with output as temp_dir:
|
||||||
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
|
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
|
||||||
@@ -244,16 +240,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
||||||
|
|
||||||
# collect ER hint info
|
# collect ER hint info
|
||||||
er_hint_data: Dict[int, Dict[int, str]] = {}
|
er_hint_data: dict[int, dict[int, str]] = {}
|
||||||
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
||||||
|
|
||||||
def write_multidata():
|
def write_multidata():
|
||||||
import NetUtils
|
import NetUtils
|
||||||
slot_data = {}
|
from NetUtils import HintStatus
|
||||||
client_versions = {}
|
slot_data: dict[int, Mapping[str, Any]] = {}
|
||||||
games = {}
|
client_versions: dict[int, tuple[int, int, int]] = {}
|
||||||
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
|
games: dict[int, str] = {}
|
||||||
slot_info = {}
|
minimum_versions: NetUtils.MinimumVersions = {
|
||||||
|
"server": AutoWorld.World.required_server_version, "clients": client_versions
|
||||||
|
}
|
||||||
|
slot_info: dict[int, NetUtils.NetworkSlot] = {}
|
||||||
names = [[name for player, name in sorted(multiworld.player_name.items())]]
|
names = [[name for player, name in sorted(multiworld.player_name.items())]]
|
||||||
for slot in multiworld.player_ids:
|
for slot in multiworld.player_ids:
|
||||||
player_world: AutoWorld.World = multiworld.worlds[slot]
|
player_world: AutoWorld.World = multiworld.worlds[slot]
|
||||||
@@ -268,15 +267,17 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
group_members=sorted(group["players"]))
|
group_members=sorted(group["players"]))
|
||||||
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
||||||
for player, world_precollected in multiworld.precollected_items.items()}
|
for player, world_precollected in multiworld.precollected_items.items()}
|
||||||
precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
|
precollected_hints: dict[int, set[NetUtils.Hint]] = {
|
||||||
|
player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))
|
||||||
|
}
|
||||||
|
|
||||||
for slot in multiworld.player_ids:
|
for slot in multiworld.player_ids:
|
||||||
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
||||||
|
|
||||||
def precollect_hint(location):
|
def precollect_hint(location: Location, auto_status: HintStatus):
|
||||||
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
|
||||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||||
location.item.code, False, entrance, location.item.flags)
|
location.item.code, False, entrance, location.item.flags, auto_status)
|
||||||
precollected_hints[location.player].add(hint)
|
precollected_hints[location.player].add(hint)
|
||||||
if location.item.player not in multiworld.groups:
|
if location.item.player not in multiworld.groups:
|
||||||
precollected_hints[location.item.player].add(hint)
|
precollected_hints[location.item.player].add(hint)
|
||||||
@@ -284,45 +285,48 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player in multiworld.groups[location.item.player]["players"]:
|
for player in multiworld.groups[location.item.player]["players"]:
|
||||||
precollected_hints[player].add(hint)
|
precollected_hints[player].add(hint)
|
||||||
|
|
||||||
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
||||||
for location in multiworld.get_filled_locations():
|
for location in multiworld.get_filled_locations():
|
||||||
if type(location.address) == int:
|
if type(location.address) == int:
|
||||||
assert location.item.code is not None, "item code None should be event, " \
|
assert location.item.code is not None, "item code None should be event, " \
|
||||||
"location.address should then also be None. Location: " \
|
"location.address should then also be None. Location: " \
|
||||||
f" {location}"
|
f" {location}, Item: {location.item}"
|
||||||
assert location.address not in locations_data[location.player], (
|
assert location.address not in locations_data[location.player], (
|
||||||
f"Locations with duplicate address. {location} and "
|
f"Locations with duplicate address. {location} and "
|
||||||
f"{locations_data[location.player][location.address]}")
|
f"{locations_data[location.player][location.address]}")
|
||||||
locations_data[location.player][location.address] = \
|
locations_data[location.player][location.address] = \
|
||||||
location.item.code, location.item.player, location.item.flags
|
location.item.code, location.item.player, location.item.flags
|
||||||
|
auto_status = HintStatus.HINT_AVOID if location.item.trap else HintStatus.HINT_PRIORITY
|
||||||
if location.name in multiworld.worlds[location.player].options.start_location_hints:
|
if location.name in multiworld.worlds[location.player].options.start_location_hints:
|
||||||
precollect_hint(location)
|
if not location.item.trap: # Unspecified status for location hints, except traps
|
||||||
|
auto_status = HintStatus.HINT_UNSPECIFIED
|
||||||
|
precollect_hint(location, auto_status)
|
||||||
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
|
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
|
||||||
precollect_hint(location)
|
precollect_hint(location, auto_status)
|
||||||
elif any([location.item.name in multiworld.worlds[player].options.start_hints
|
elif any([location.item.name in multiworld.worlds[player].options.start_hints
|
||||||
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
|
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
|
||||||
precollect_hint(location)
|
precollect_hint(location, auto_status)
|
||||||
|
|
||||||
# embedded data package
|
# embedded data package
|
||||||
data_package = {
|
data_package = {
|
||||||
game_world.game: worlds.network_data_package["games"][game_world.game]
|
game_world.game: worlds.network_data_package["games"][game_world.game]
|
||||||
for game_world in multiworld.worlds.values()
|
for game_world in multiworld.worlds.values()
|
||||||
}
|
}
|
||||||
|
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
|
||||||
|
|
||||||
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
|
checks_in_area: dict[int, dict[str, int | list[int]]] = {}
|
||||||
|
|
||||||
# get spheres -> filter address==None -> skip empty
|
# get spheres -> filter address==None -> skip empty
|
||||||
spheres: List[Dict[int, Set[int]]] = []
|
spheres: list[dict[int, set[int]]] = []
|
||||||
for sphere in multiworld.get_spheres():
|
for sphere in multiworld.get_sendable_spheres():
|
||||||
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
|
current_sphere: dict[int, set[int]] = collections.defaultdict(set)
|
||||||
for sphere_location in sphere:
|
for sphere_location in sphere:
|
||||||
if type(sphere_location.address) is int:
|
current_sphere[sphere_location.player].add(sphere_location.address)
|
||||||
current_sphere[sphere_location.player].add(sphere_location.address)
|
|
||||||
|
|
||||||
if current_sphere:
|
if current_sphere:
|
||||||
spheres.append(dict(current_sphere))
|
spheres.append(dict(current_sphere))
|
||||||
|
|
||||||
multidata = {
|
multidata: NetUtils.MultiData = {
|
||||||
"slot_data": slot_data,
|
"slot_data": slot_data,
|
||||||
"slot_info": slot_info,
|
"slot_info": slot_info,
|
||||||
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
|
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
|
||||||
@@ -332,7 +336,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
"er_hint_data": er_hint_data,
|
"er_hint_data": er_hint_data,
|
||||||
"precollected_items": precollected_items,
|
"precollected_items": precollected_items,
|
||||||
"precollected_hints": precollected_hints,
|
"precollected_hints": precollected_hints,
|
||||||
"version": tuple(version_tuple),
|
"version": (version_tuple.major, version_tuple.minor, version_tuple.build),
|
||||||
"tags": ["AP"],
|
"tags": ["AP"],
|
||||||
"minimum_versions": minimum_versions,
|
"minimum_versions": minimum_versions,
|
||||||
"seed_name": multiworld.seed_name,
|
"seed_name": multiworld.seed_name,
|
||||||
@@ -340,13 +344,17 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
"datapackage": data_package,
|
"datapackage": data_package,
|
||||||
"race_mode": int(multiworld.is_race),
|
"race_mode": int(multiworld.is_race),
|
||||||
}
|
}
|
||||||
|
# TODO: change to `"version": version_tuple` after getting better serialization
|
||||||
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
||||||
|
|
||||||
multidata = zlib.compress(pickle.dumps(multidata), 9)
|
for key in ("slot_data", "er_hint_data"):
|
||||||
|
multidata[key] = convert_to_base_types(multidata[key])
|
||||||
|
|
||||||
|
serialized_multidata = zlib.compress(restricted_dumps(multidata), 9)
|
||||||
|
|
||||||
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
||||||
f.write(bytes([3])) # version of format
|
f.write(bytes([3])) # version of format
|
||||||
f.write(multidata)
|
f.write(serialized_multidata)
|
||||||
|
|
||||||
output_file_futures.append(pool.submit(write_multidata))
|
output_file_futures.append(pool.submit(write_multidata))
|
||||||
if not check_accessibility_task.result():
|
if not check_accessibility_task.result():
|
||||||
|
|||||||
@@ -1,344 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
import atexit
|
|
||||||
import shutil
|
|
||||||
from subprocess import Popen
|
|
||||||
from shutil import copyfile
|
|
||||||
from time import strftime
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
import Utils
|
|
||||||
from Utils import is_windows
|
|
||||||
|
|
||||||
atexit.register(input, "Press enter to exit.")
|
|
||||||
|
|
||||||
# 1 or more digits followed by m or g, then optional b
|
|
||||||
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_yes_no(prompt):
|
|
||||||
yes_inputs = {'yes', 'ye', 'y'}
|
|
||||||
no_inputs = {'no', 'n'}
|
|
||||||
while True:
|
|
||||||
choice = input(prompt + " [y/n] ").lower()
|
|
||||||
if choice in yes_inputs:
|
|
||||||
return True
|
|
||||||
elif choice in no_inputs:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
print('Please respond with "y" or "n".')
|
|
||||||
|
|
||||||
|
|
||||||
def find_ap_randomizer_jar(forge_dir):
|
|
||||||
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
|
|
||||||
mods_dir = os.path.join(forge_dir, 'mods')
|
|
||||||
if os.path.isdir(mods_dir):
|
|
||||||
for entry in os.scandir(mods_dir):
|
|
||||||
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
|
|
||||||
logging.info(f"Found AP randomizer mod: {entry.name}")
|
|
||||||
return entry.name
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
os.mkdir(mods_dir)
|
|
||||||
logging.info(f"Created mods folder in {forge_dir}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def replace_apmc_files(forge_dir, apmc_file):
|
|
||||||
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
|
|
||||||
if apmc_file is None:
|
|
||||||
return
|
|
||||||
apdata_dir = os.path.join(forge_dir, 'APData')
|
|
||||||
copy_apmc = True
|
|
||||||
if not os.path.isdir(apdata_dir):
|
|
||||||
os.mkdir(apdata_dir)
|
|
||||||
logging.info(f"Created APData folder in {forge_dir}")
|
|
||||||
for entry in os.scandir(apdata_dir):
|
|
||||||
if entry.name.endswith(".apmc") and entry.is_file():
|
|
||||||
if not os.path.samefile(apmc_file, entry.path):
|
|
||||||
os.remove(entry.path)
|
|
||||||
logging.info(f"Removed {entry.name} in {apdata_dir}")
|
|
||||||
else: # apmc already in apdata
|
|
||||||
copy_apmc = False
|
|
||||||
if copy_apmc:
|
|
||||||
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
|
|
||||||
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
|
|
||||||
|
|
||||||
|
|
||||||
def read_apmc_file(apmc_file):
|
|
||||||
from base64 import b64decode
|
|
||||||
|
|
||||||
with open(apmc_file, 'r') as f:
|
|
||||||
return json.loads(b64decode(f.read()))
|
|
||||||
|
|
||||||
|
|
||||||
def update_mod(forge_dir, url: str):
|
|
||||||
"""Check mod version, download new mod from GitHub releases page if needed. """
|
|
||||||
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
|
||||||
os.path.basename(url)
|
|
||||||
if ap_randomizer is not None:
|
|
||||||
logging.info(f"Your current mod is {ap_randomizer}.")
|
|
||||||
else:
|
|
||||||
logging.info(f"You do not have the AP randomizer mod installed.")
|
|
||||||
|
|
||||||
if ap_randomizer != os.path.basename(url):
|
|
||||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
|
||||||
f"{os.path.basename(url)}")
|
|
||||||
if prompt_yes_no("Would you like to update?"):
|
|
||||||
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
|
|
||||||
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
|
|
||||||
logging.info("Downloading AP randomizer mod. This may take a moment...")
|
|
||||||
apmod_resp = requests.get(url)
|
|
||||||
if apmod_resp.status_code == 200:
|
|
||||||
with open(new_ap_mod, 'wb') as f:
|
|
||||||
f.write(apmod_resp.content)
|
|
||||||
logging.info(f"Wrote new mod file to {new_ap_mod}")
|
|
||||||
if old_ap_mod is not None:
|
|
||||||
os.remove(old_ap_mod)
|
|
||||||
logging.info(f"Removed old mod file from {old_ap_mod}")
|
|
||||||
else:
|
|
||||||
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
|
||||||
logging.error(f"Please report this issue on the Archipelago Discord server.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def check_eula(forge_dir):
|
|
||||||
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
|
|
||||||
eula_path = os.path.join(forge_dir, "eula.txt")
|
|
||||||
if not os.path.isfile(eula_path):
|
|
||||||
# Create eula.txt
|
|
||||||
with open(eula_path, 'w') as f:
|
|
||||||
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
|
|
||||||
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
|
|
||||||
f.write("eula=false\n")
|
|
||||||
with open(eula_path, 'r+') as f:
|
|
||||||
text = f.read()
|
|
||||||
if 'false' in text:
|
|
||||||
# Prompt user to agree to the EULA
|
|
||||||
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
|
|
||||||
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
|
|
||||||
if prompt_yes_no("Do you agree to the EULA?"):
|
|
||||||
f.seek(0)
|
|
||||||
f.write(text.replace('false', 'true'))
|
|
||||||
f.truncate()
|
|
||||||
logging.info(f"Set {eula_path} to true")
|
|
||||||
else:
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def find_jdk_dir(version: str) -> str:
|
|
||||||
"""get the specified versions jdk directory"""
|
|
||||||
for entry in os.listdir():
|
|
||||||
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
|
|
||||||
return os.path.abspath(entry)
|
|
||||||
|
|
||||||
|
|
||||||
def find_jdk(version: str) -> str:
|
|
||||||
"""get the java exe location"""
|
|
||||||
|
|
||||||
if is_windows:
|
|
||||||
jdk = find_jdk_dir(version)
|
|
||||||
jdk_exe = os.path.join(jdk, "bin", "java.exe")
|
|
||||||
if os.path.isfile(jdk_exe):
|
|
||||||
return jdk_exe
|
|
||||||
else:
|
|
||||||
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
|
|
||||||
if not jdk_exe:
|
|
||||||
raise Exception("Could not find Java. Is Java installed on the system?")
|
|
||||||
return jdk_exe
|
|
||||||
|
|
||||||
|
|
||||||
def download_java(java: str):
|
|
||||||
"""Download Corretto (Amazon JDK)"""
|
|
||||||
|
|
||||||
jdk = find_jdk_dir(java)
|
|
||||||
if jdk is not None:
|
|
||||||
print(f"Removing old JDK...")
|
|
||||||
from shutil import rmtree
|
|
||||||
rmtree(jdk)
|
|
||||||
|
|
||||||
print(f"Downloading Java...")
|
|
||||||
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
|
|
||||||
resp = requests.get(jdk_url)
|
|
||||||
if resp.status_code == 200: # OK
|
|
||||||
print(f"Extracting...")
|
|
||||||
import zipfile
|
|
||||||
from io import BytesIO
|
|
||||||
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
|
|
||||||
zf.extractall()
|
|
||||||
else:
|
|
||||||
print(f"Error downloading Java (status code {resp.status_code}).")
|
|
||||||
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
|
|
||||||
if not prompt_yes_no("Continue anyways?"):
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def install_forge(directory: str, forge_version: str, java_version: str):
|
|
||||||
"""download and install forge"""
|
|
||||||
|
|
||||||
java_exe = find_jdk(java_version)
|
|
||||||
if java_exe is not None:
|
|
||||||
print(f"Downloading Forge {forge_version}...")
|
|
||||||
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
|
|
||||||
resp = requests.get(forge_url)
|
|
||||||
if resp.status_code == 200: # OK
|
|
||||||
forge_install_jar = os.path.join(directory, "forge_install.jar")
|
|
||||||
if not os.path.exists(directory):
|
|
||||||
os.mkdir(directory)
|
|
||||||
with open(forge_install_jar, 'wb') as f:
|
|
||||||
f.write(resp.content)
|
|
||||||
print(f"Installing Forge...")
|
|
||||||
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
|
|
||||||
install_process.wait()
|
|
||||||
os.remove(forge_install_jar)
|
|
||||||
|
|
||||||
|
|
||||||
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
|
|
||||||
"""Run the Forge server."""
|
|
||||||
|
|
||||||
java_exe = find_jdk(java_version)
|
|
||||||
if not os.path.isfile(java_exe):
|
|
||||||
java_exe = "java" # try to fall back on java in the PATH
|
|
||||||
|
|
||||||
heap_arg = max_heap_re.match(heap_arg).group()
|
|
||||||
if heap_arg[-1] in ['b', 'B']:
|
|
||||||
heap_arg = heap_arg[:-1]
|
|
||||||
heap_arg = "-Xmx" + heap_arg
|
|
||||||
|
|
||||||
os_args = "win_args.txt" if is_windows else "unix_args.txt"
|
|
||||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
|
|
||||||
forge_args = []
|
|
||||||
with open(args_file) as argfile:
|
|
||||||
for line in argfile:
|
|
||||||
forge_args.extend(line.strip().split(" "))
|
|
||||||
|
|
||||||
args = [java_exe, heap_arg, *forge_args, "-nogui"]
|
|
||||||
logging.info(f"Running Forge server: {args}")
|
|
||||||
os.chdir(forge_dir)
|
|
||||||
return Popen(args)
|
|
||||||
|
|
||||||
|
|
||||||
def get_minecraft_versions(version, release_channel="release"):
|
|
||||||
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
|
|
||||||
resp = requests.get(version_file_endpoint)
|
|
||||||
local = False
|
|
||||||
if resp.status_code == 200: # OK
|
|
||||||
try:
|
|
||||||
data = resp.json()
|
|
||||||
except requests.exceptions.JSONDecodeError:
|
|
||||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
|
||||||
local = True
|
|
||||||
else:
|
|
||||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
|
||||||
local = True
|
|
||||||
|
|
||||||
if local:
|
|
||||||
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
else:
|
|
||||||
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
|
|
||||||
json.dump(data, f)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if version:
|
|
||||||
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
|
||||||
else:
|
|
||||||
return resp.json()[release_channel][0]
|
|
||||||
except (StopIteration, KeyError):
|
|
||||||
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
|
|
||||||
if release_channel != "release":
|
|
||||||
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
|
|
||||||
else:
|
|
||||||
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def is_correct_forge(forge_dir) -> bool:
|
|
||||||
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
Utils.init_logging("MinecraftClient")
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
|
|
||||||
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
|
|
||||||
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
|
|
||||||
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
|
|
||||||
help="Specify release channel to use.")
|
|
||||||
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
|
|
||||||
help="specify java version.")
|
|
||||||
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
|
|
||||||
help="specify forge version. (Minecraft Version-Forge Version)")
|
|
||||||
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
|
|
||||||
help="specify Mod data version to download.")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
|
|
||||||
|
|
||||||
# Change to executable's working directory
|
|
||||||
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
|
|
||||||
|
|
||||||
options = Utils.get_options()
|
|
||||||
channel = args.channel or options["minecraft_options"]["release_channel"]
|
|
||||||
apmc_data = None
|
|
||||||
data_version = args.data_version or None
|
|
||||||
|
|
||||||
if apmc_file is None and not args.install:
|
|
||||||
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
|
|
||||||
|
|
||||||
if apmc_file is not None and data_version is None:
|
|
||||||
apmc_data = read_apmc_file(apmc_file)
|
|
||||||
data_version = apmc_data.get('client_version', '')
|
|
||||||
|
|
||||||
versions = get_minecraft_versions(data_version, channel)
|
|
||||||
|
|
||||||
forge_dir = options["minecraft_options"]["forge_directory"]
|
|
||||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
|
||||||
forge_version = args.forge or versions["forge"]
|
|
||||||
java_version = args.java or versions["java"]
|
|
||||||
mod_url = versions["url"]
|
|
||||||
java_dir = find_jdk_dir(java_version)
|
|
||||||
|
|
||||||
if args.install:
|
|
||||||
if is_windows:
|
|
||||||
print("Installing Java")
|
|
||||||
download_java(java_version)
|
|
||||||
if not is_correct_forge(forge_dir):
|
|
||||||
print("Installing Minecraft Forge")
|
|
||||||
install_forge(forge_dir, forge_version, java_version)
|
|
||||||
else:
|
|
||||||
print("Correct Forge version already found, skipping install.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
if apmc_data is None:
|
|
||||||
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
|
|
||||||
|
|
||||||
if is_windows:
|
|
||||||
if java_dir is None or not os.path.isdir(java_dir):
|
|
||||||
if prompt_yes_no("Did not find java directory. Download and install java now?"):
|
|
||||||
download_java(java_version)
|
|
||||||
java_dir = find_jdk_dir(java_version)
|
|
||||||
if java_dir is None or not os.path.isdir(java_dir):
|
|
||||||
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
|
|
||||||
|
|
||||||
if not is_correct_forge(forge_dir):
|
|
||||||
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
|
|
||||||
install_forge(forge_dir, forge_version, java_version)
|
|
||||||
if not os.path.isdir(forge_dir):
|
|
||||||
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
|
|
||||||
|
|
||||||
if not max_heap_re.match(max_heap):
|
|
||||||
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
|
|
||||||
|
|
||||||
update_mod(forge_dir, mod_url)
|
|
||||||
replace_apmc_files(forge_dir, apmc_file)
|
|
||||||
check_eula(forge_dir)
|
|
||||||
server_process = run_forge_server(forge_dir, java_version, max_heap)
|
|
||||||
server_process.wait()
|
|
||||||
@@ -5,11 +5,22 @@ import multiprocessing
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
|
||||||
if sys.version_info < (3, 10, 11):
|
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9):
|
||||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.11+ is supported.")
|
# Official micro version updates. This should match the number in docs/running from source.md.
|
||||||
|
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.11.9+ is supported.")
|
||||||
|
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 13):
|
||||||
|
# There are known security issues, but no easy way to install fixed versions on Windows for testing.
|
||||||
|
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
|
||||||
|
elif sys.version_info < (3, 11, 0):
|
||||||
|
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
|
||||||
|
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0+ is supported.")
|
||||||
|
|
||||||
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
||||||
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
|
_skip_update = bool(
|
||||||
|
getattr(sys, "frozen", False) or
|
||||||
|
multiprocessing.parent_process() or
|
||||||
|
os.environ.get("SKIP_REQUIREMENTS_UPDATE", "").lower() in ("1", "true", "yes")
|
||||||
|
)
|
||||||
update_ran = _skip_update
|
update_ran = _skip_update
|
||||||
|
|
||||||
|
|
||||||
@@ -63,11 +74,11 @@ def update_command():
|
|||||||
def install_pkg_resources(yes=False):
|
def install_pkg_resources(yes=False):
|
||||||
try:
|
try:
|
||||||
import pkg_resources # noqa: F401
|
import pkg_resources # noqa: F401
|
||||||
except ImportError:
|
except (AttributeError, ImportError):
|
||||||
check_pip()
|
check_pip()
|
||||||
if not yes:
|
if not yes:
|
||||||
confirm("pkg_resources not found, press enter to install it")
|
confirm("pkg_resources not found, press enter to install it")
|
||||||
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
|
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"])
|
||||||
|
|
||||||
|
|
||||||
def update(yes: bool = False, force: bool = False) -> None:
|
def update(yes: bool = False, force: bool = False) -> None:
|
||||||
|
|||||||
506
MultiServer.py
506
MultiServer.py
@@ -28,9 +28,11 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
import ssl
|
import ssl
|
||||||
|
from NetUtils import ServerConnection
|
||||||
|
|
||||||
import websockets
|
|
||||||
import colorama
|
import colorama
|
||||||
|
import websockets
|
||||||
|
from websockets.extensions.permessage_deflate import PerMessageDeflate, ServerPerMessageDeflateFactory
|
||||||
try:
|
try:
|
||||||
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
||||||
from pony.orm.dbapiprovider import OperationalError
|
from pony.orm.dbapiprovider import OperationalError
|
||||||
@@ -41,10 +43,21 @@ import NetUtils
|
|||||||
import Utils
|
import Utils
|
||||||
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
||||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||||
SlotType, LocationStore
|
SlotType, LocationStore, MultiData, Hint, HintStatus
|
||||||
|
from BaseClasses import ItemClassification
|
||||||
|
|
||||||
min_client_version = Version(0, 1, 6)
|
|
||||||
colorama.init()
|
min_client_version = Version(0, 5, 0)
|
||||||
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
|
no_version = Version(0, 0, 0)
|
||||||
|
assert isinstance(no_version, tuple) # assert immutable
|
||||||
|
|
||||||
|
server_per_message_deflate_factory = ServerPerMessageDeflateFactory(
|
||||||
|
server_max_window_bits=11,
|
||||||
|
client_max_window_bits=11,
|
||||||
|
compress_settings={"memLevel": 4},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def remove_from_list(container, value):
|
def remove_from_list(container, value):
|
||||||
@@ -63,9 +76,13 @@ def pop_from_container(container, value):
|
|||||||
return container
|
return container
|
||||||
|
|
||||||
|
|
||||||
def update_dict(dictionary, entries):
|
def update_container_unique(container, entries):
|
||||||
dictionary.update(entries)
|
if isinstance(container, list):
|
||||||
return dictionary
|
existing_container_as_set = set(container)
|
||||||
|
container.extend([entry for entry in entries if entry not in existing_container_as_set])
|
||||||
|
else:
|
||||||
|
container.update(entries)
|
||||||
|
return container
|
||||||
|
|
||||||
|
|
||||||
def queue_gc():
|
def queue_gc():
|
||||||
@@ -106,7 +123,7 @@ modify_functions = {
|
|||||||
# lists/dicts:
|
# lists/dicts:
|
||||||
"remove": remove_from_list,
|
"remove": remove_from_list,
|
||||||
"pop": pop_from_container,
|
"pop": pop_from_container,
|
||||||
"update": update_dict,
|
"update": update_container_unique,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -117,15 +134,40 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
|||||||
|
|
||||||
|
|
||||||
class Client(Endpoint):
|
class Client(Endpoint):
|
||||||
version = Version(0, 0, 0)
|
__slots__ = (
|
||||||
tags: typing.List[str] = []
|
"__weakref__",
|
||||||
|
"version",
|
||||||
|
"auth",
|
||||||
|
"team",
|
||||||
|
"slot",
|
||||||
|
"send_index",
|
||||||
|
"tags",
|
||||||
|
"messageprocessor",
|
||||||
|
"ctx",
|
||||||
|
"remote_items",
|
||||||
|
"remote_start_inventory",
|
||||||
|
"no_items",
|
||||||
|
"no_locations",
|
||||||
|
"no_text",
|
||||||
|
)
|
||||||
|
|
||||||
|
version: Version
|
||||||
|
auth: bool
|
||||||
|
team: int | None
|
||||||
|
slot: int | None
|
||||||
|
send_index: int
|
||||||
|
tags: list[str]
|
||||||
|
messageprocessor: ClientMessageProcessor
|
||||||
|
ctx: weakref.ref[Context]
|
||||||
remote_items: bool
|
remote_items: bool
|
||||||
remote_start_inventory: bool
|
remote_start_inventory: bool
|
||||||
no_items: bool
|
no_items: bool
|
||||||
no_locations: bool
|
no_locations: bool
|
||||||
|
no_text: bool
|
||||||
|
|
||||||
def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
|
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
|
||||||
super().__init__(socket)
|
super().__init__(socket)
|
||||||
|
self.version = no_version
|
||||||
self.auth = False
|
self.auth = False
|
||||||
self.team = None
|
self.team = None
|
||||||
self.slot = None
|
self.slot = None
|
||||||
@@ -133,6 +175,11 @@ class Client(Endpoint):
|
|||||||
self.tags = []
|
self.tags = []
|
||||||
self.messageprocessor = client_message_processor(ctx, self)
|
self.messageprocessor = client_message_processor(ctx, self)
|
||||||
self.ctx = weakref.ref(ctx)
|
self.ctx = weakref.ref(ctx)
|
||||||
|
self.remote_items = False
|
||||||
|
self.remote_start_inventory = False
|
||||||
|
self.no_items = False
|
||||||
|
self.no_locations = False
|
||||||
|
self.no_text = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def items_handling(self):
|
def items_handling(self):
|
||||||
@@ -170,10 +217,12 @@ class Context:
|
|||||||
"release_mode": str,
|
"release_mode": str,
|
||||||
"remaining_mode": str,
|
"remaining_mode": str,
|
||||||
"collect_mode": str,
|
"collect_mode": str,
|
||||||
|
"countdown_mode": str,
|
||||||
"item_cheat": bool,
|
"item_cheat": bool,
|
||||||
"compatibility": int}
|
"compatibility": int}
|
||||||
# team -> slot id -> list of clients authenticated to slot.
|
# team -> slot id -> list of clients authenticated to slot.
|
||||||
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
||||||
|
endpoints: list[Client]
|
||||||
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
||||||
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
||||||
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
||||||
@@ -198,8 +247,8 @@ class Context:
|
|||||||
|
|
||||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||||
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
||||||
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
|
||||||
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
super(Context, self).__init__()
|
super(Context, self).__init__()
|
||||||
self.slot_info = {}
|
self.slot_info = {}
|
||||||
@@ -228,10 +277,11 @@ class Context:
|
|||||||
self.hint_cost = hint_cost
|
self.hint_cost = hint_cost
|
||||||
self.location_check_points = location_check_points
|
self.location_check_points = location_check_points
|
||||||
self.hints_used = collections.defaultdict(int)
|
self.hints_used = collections.defaultdict(int)
|
||||||
self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
|
self.hints: typing.Dict[team_slot, typing.Set[Hint]] = collections.defaultdict(set)
|
||||||
self.release_mode: str = release_mode
|
self.release_mode: str = release_mode
|
||||||
self.remaining_mode: str = remaining_mode
|
self.remaining_mode: str = remaining_mode
|
||||||
self.collect_mode: str = collect_mode
|
self.collect_mode: str = collect_mode
|
||||||
|
self.countdown_mode: str = countdown_mode
|
||||||
self.item_cheat = item_cheat
|
self.item_cheat = item_cheat
|
||||||
self.exit_event = asyncio.Event()
|
self.exit_event = asyncio.Event()
|
||||||
self.client_activity_timers: typing.Dict[
|
self.client_activity_timers: typing.Dict[
|
||||||
@@ -363,18 +413,28 @@ class Context:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def broadcast_all(self, msgs: typing.List[dict]):
|
def broadcast_all(self, msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||||
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
|
data = self.dumper(msgs)
|
||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
endpoints = (
|
||||||
|
endpoint
|
||||||
|
for endpoint in self.endpoints
|
||||||
|
if endpoint.auth and not (msg_is_text and endpoint.no_text)
|
||||||
|
)
|
||||||
|
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
||||||
|
|
||||||
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
||||||
self.logger.info("Notice (all): %s" % text)
|
self.logger.info("Notice (all): %s" % text)
|
||||||
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
||||||
|
|
||||||
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||||
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
|
data = self.dumper(msgs)
|
||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
endpoints = (
|
||||||
|
endpoint
|
||||||
|
for endpoint in itertools.chain.from_iterable(self.clients[team].values())
|
||||||
|
if not (msg_is_text and endpoint.no_text)
|
||||||
|
)
|
||||||
|
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
||||||
|
|
||||||
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msgs = self.dumper(msgs)
|
||||||
@@ -388,13 +448,13 @@ class Context:
|
|||||||
await on_client_disconnected(self, endpoint)
|
await on_client_disconnected(self, endpoint)
|
||||||
|
|
||||||
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
||||||
if not client.auth:
|
if not client.auth or client.no_text:
|
||||||
return
|
return
|
||||||
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||||
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
||||||
|
|
||||||
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
||||||
if not client.auth:
|
if not client.auth or client.no_text:
|
||||||
return
|
return
|
||||||
async_start(self.send_msgs(client,
|
async_start(self.send_msgs(client,
|
||||||
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
|
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
|
||||||
@@ -425,7 +485,7 @@ class Context:
|
|||||||
raise Utils.VersionException("Incompatible multidata.")
|
raise Utils.VersionException("Incompatible multidata.")
|
||||||
return restricted_loads(zlib.decompress(data[1:]))
|
return restricted_loads(zlib.decompress(data[1:]))
|
||||||
|
|
||||||
def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
|
def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any],
|
||||||
use_embedded_server_options: bool):
|
use_embedded_server_options: bool):
|
||||||
|
|
||||||
self.read_data = {}
|
self.read_data = {}
|
||||||
@@ -433,17 +493,21 @@ class Context:
|
|||||||
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
|
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
|
||||||
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
||||||
if mdata_ver > version_tuple:
|
if mdata_ver > version_tuple:
|
||||||
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
|
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, "
|
||||||
f"however this server is of version {version_tuple}")
|
f"however this server is of version {version_tuple}")
|
||||||
self.generator_version = Version(*decoded_obj["version"])
|
self.generator_version = Version(*decoded_obj["version"])
|
||||||
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
||||||
self.minimum_client_versions = {}
|
self.minimum_client_versions = {}
|
||||||
|
if self.generator_version < Version(0, 6, 2):
|
||||||
|
min_version = Version(0, 1, 6)
|
||||||
|
else:
|
||||||
|
min_version = min_client_version
|
||||||
for player, version in clients_ver.items():
|
for player, version in clients_ver.items():
|
||||||
self.minimum_client_versions[player] = max(Version(*version), min_client_version)
|
self.minimum_client_versions[player] = max(Version(*version), min_version)
|
||||||
|
|
||||||
self.slot_info = decoded_obj["slot_info"]
|
self.slot_info = decoded_obj["slot_info"]
|
||||||
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
||||||
self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
|
self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items()
|
||||||
if slot_info.type == SlotType.group}
|
if slot_info.type == SlotType.group}
|
||||||
|
|
||||||
self.clients = {0: {}}
|
self.clients = {0: {}}
|
||||||
@@ -522,6 +586,7 @@ class Context:
|
|||||||
|
|
||||||
def _save(self, exit_save: bool = False) -> bool:
|
def _save(self, exit_save: bool = False) -> bool:
|
||||||
try:
|
try:
|
||||||
|
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
|
||||||
encoded_save = pickle.dumps(self.get_save())
|
encoded_save = pickle.dumps(self.get_save())
|
||||||
with open(self.save_filename, "wb") as f:
|
with open(self.save_filename, "wb") as f:
|
||||||
f.write(zlib.compress(encoded_save))
|
f.write(zlib.compress(encoded_save))
|
||||||
@@ -602,6 +667,7 @@ class Context:
|
|||||||
"server_password": self.server_password, "password": self.password,
|
"server_password": self.server_password, "password": self.password,
|
||||||
"release_mode": self.release_mode,
|
"release_mode": self.release_mode,
|
||||||
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
|
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
|
||||||
|
"countdown_mode": self.countdown_mode,
|
||||||
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -636,6 +702,7 @@ class Context:
|
|||||||
self.release_mode = savedata["game_options"]["release_mode"]
|
self.release_mode = savedata["game_options"]["release_mode"]
|
||||||
self.remaining_mode = savedata["game_options"]["remaining_mode"]
|
self.remaining_mode = savedata["game_options"]["remaining_mode"]
|
||||||
self.collect_mode = savedata["game_options"]["collect_mode"]
|
self.collect_mode = savedata["game_options"]["collect_mode"]
|
||||||
|
self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode)
|
||||||
self.item_cheat = savedata["game_options"]["item_cheat"]
|
self.item_cheat = savedata["game_options"]["item_cheat"]
|
||||||
self.compatibility = savedata["game_options"]["compatibility"]
|
self.compatibility = savedata["game_options"]["compatibility"]
|
||||||
|
|
||||||
@@ -656,13 +723,29 @@ class Context:
|
|||||||
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
|
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None):
|
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None,
|
||||||
|
changed: typing.Optional[typing.Set[team_slot]] = None) -> None:
|
||||||
|
"""Refreshes the hints for the specified team/slot. Providing 'None' for either team or slot
|
||||||
|
will refresh all teams or all slots respectively. If a set is passed for 'changed', each (team,slot)
|
||||||
|
pair that has at least one hint modified will be added to the set.
|
||||||
|
"""
|
||||||
for hint_team, hint_slot in self.hints:
|
for hint_team, hint_slot in self.hints:
|
||||||
if (team is None or team == hint_team) and (slot is None or slot == hint_slot):
|
if team != hint_team and team is not None:
|
||||||
self.hints[hint_team, hint_slot] = {
|
continue # Check specified team only, all if team is None
|
||||||
hint.re_check(self, hint_team) for hint in
|
if slot != hint_slot and slot is not None:
|
||||||
self.hints[hint_team, hint_slot]
|
continue # Check specified slot only, all if slot is None
|
||||||
}
|
new_hints: typing.Set[Hint] = set()
|
||||||
|
for hint in self.hints[hint_team, hint_slot]:
|
||||||
|
new_hint = hint.re_check(self, hint_team)
|
||||||
|
new_hints.add(new_hint)
|
||||||
|
if hint == new_hint:
|
||||||
|
continue
|
||||||
|
for player in self.slot_set(hint.receiving_player) | {hint.finding_player}:
|
||||||
|
if changed is not None:
|
||||||
|
changed.add((hint_team,player))
|
||||||
|
if slot is not None and slot != player:
|
||||||
|
self.replace_hint(hint_team, player, hint, new_hint)
|
||||||
|
self.hints[hint_team, hint_slot] = new_hints
|
||||||
|
|
||||||
def get_rechecked_hints(self, team: int, slot: int):
|
def get_rechecked_hints(self, team: int, slot: int):
|
||||||
self.recheck_hints(team, slot)
|
self.recheck_hints(team, slot)
|
||||||
@@ -711,8 +794,8 @@ class Context:
|
|||||||
else:
|
else:
|
||||||
return self.player_names[team, slot]
|
return self.player_names[team, slot]
|
||||||
|
|
||||||
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False,
|
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
|
||||||
recipients: typing.Sequence[int] = None):
|
persist_even_if_found: bool = False, recipients: typing.Sequence[int] = None):
|
||||||
"""Send and remember hints."""
|
"""Send and remember hints."""
|
||||||
if only_new:
|
if only_new:
|
||||||
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
|
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
|
||||||
@@ -726,29 +809,42 @@ class Context:
|
|||||||
concerns[player].append(data)
|
concerns[player].append(data)
|
||||||
if not hint.local and data not in concerns[hint.finding_player]:
|
if not hint.local and data not in concerns[hint.finding_player]:
|
||||||
concerns[hint.finding_player].append(data)
|
concerns[hint.finding_player].append(data)
|
||||||
# remember hints in all cases
|
|
||||||
|
|
||||||
# since hints are bidirectional, finding player and receiving player,
|
# For !hint use cases, only hints that were not already found at the time of creation should be remembered
|
||||||
# we can check once if hint already exists
|
# For LocationScouts use-cases, all hints should be remembered
|
||||||
if hint not in self.hints[team, hint.finding_player]:
|
if not hint.found or persist_even_if_found:
|
||||||
self.hints[team, hint.finding_player].add(hint)
|
# since hints are bidirectional, finding player and receiving player,
|
||||||
new_hint_events.add(hint.finding_player)
|
# we can check once if hint already exists
|
||||||
for player in self.slot_set(hint.receiving_player):
|
if hint not in self.hints[team, hint.finding_player]:
|
||||||
self.hints[team, player].add(hint)
|
self.hints[team, hint.finding_player].add(hint)
|
||||||
new_hint_events.add(player)
|
new_hint_events.add(hint.finding_player)
|
||||||
|
for player in self.slot_set(hint.receiving_player):
|
||||||
|
self.hints[team, player].add(hint)
|
||||||
|
new_hint_events.add(player)
|
||||||
|
|
||||||
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
|
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
|
||||||
for slot in new_hint_events:
|
for slot in new_hint_events:
|
||||||
self.on_new_hint(team, slot)
|
self.on_new_hint(team, slot)
|
||||||
for slot, hint_data in concerns.items():
|
for slot, hint_data in concerns.items():
|
||||||
if recipients is None or slot in recipients:
|
if recipients is None or slot in recipients:
|
||||||
clients = self.clients[team].get(slot)
|
clients = filter(lambda c: not c.no_text, self.clients[team].get(slot, []))
|
||||||
if not clients:
|
if not clients:
|
||||||
continue
|
continue
|
||||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
|
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
|
||||||
for client in clients:
|
for client in clients:
|
||||||
async_start(self.send_msgs(client, client_hints))
|
async_start(self.send_msgs(client, client_hints))
|
||||||
|
|
||||||
|
def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]:
|
||||||
|
for hint in self.hints[team, finding_player]:
|
||||||
|
if hint.location == seeked_location and hint.finding_player == finding_player:
|
||||||
|
return hint
|
||||||
|
return None
|
||||||
|
|
||||||
|
def replace_hint(self, team: int, slot: int, old_hint: Hint, new_hint: Hint) -> None:
|
||||||
|
if old_hint in self.hints[team, slot]:
|
||||||
|
self.hints[team, slot].remove(old_hint)
|
||||||
|
self.hints[team, slot].add(new_hint)
|
||||||
|
|
||||||
# "events"
|
# "events"
|
||||||
|
|
||||||
def on_goal_achieved(self, client: Client):
|
def on_goal_achieved(self, client: Client):
|
||||||
@@ -790,7 +886,7 @@ def update_aliases(ctx: Context, team: int):
|
|||||||
async_start(ctx.send_encoded_msgs(client, cmd))
|
async_start(ctx.send_encoded_msgs(client, cmd))
|
||||||
|
|
||||||
|
|
||||||
async def server(websocket, path: str = "/", ctx: Context = None):
|
async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None:
|
||||||
client = Client(websocket, ctx)
|
client = Client(websocket, ctx)
|
||||||
ctx.endpoints.append(client)
|
ctx.endpoints.append(client)
|
||||||
|
|
||||||
@@ -881,6 +977,10 @@ async def on_client_joined(ctx: Context, client: Client):
|
|||||||
"If your client supports it, "
|
"If your client supports it, "
|
||||||
"you may have additional local commands you can list with /help.",
|
"you may have additional local commands you can list with /help.",
|
||||||
{"type": "Tutorial"})
|
{"type": "Tutorial"})
|
||||||
|
if not any(isinstance(extension, PerMessageDeflate) for extension in client.socket.extensions):
|
||||||
|
ctx.notify_client(client, "Warning: your client does not support compressed websocket connections! "
|
||||||
|
"It may stop working in the future. If you are a player, please report this to the "
|
||||||
|
"client's developer.")
|
||||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
@@ -947,9 +1047,13 @@ def get_status_string(ctx: Context, team: int, tag: str):
|
|||||||
tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags])
|
tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags])
|
||||||
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
|
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
|
||||||
tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else ""
|
tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else ""
|
||||||
goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
|
status_text = (
|
||||||
|
" and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else
|
||||||
|
" and is ready." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_READY else
|
||||||
|
"."
|
||||||
|
)
|
||||||
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
|
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
|
||||||
f"{tag_text}{goal_text} {completion_text}"
|
f"{tag_text}{status_text} {completion_text}"
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -1027,21 +1131,37 @@ def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem
|
|||||||
|
|
||||||
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
|
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
|
||||||
count_activity: bool = True):
|
count_activity: bool = True):
|
||||||
|
slot_locations = ctx.locations[slot]
|
||||||
new_locations = set(locations) - ctx.location_checks[team, slot]
|
new_locations = set(locations) - ctx.location_checks[team, slot]
|
||||||
new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata
|
new_locations.intersection_update(slot_locations) # ignore location IDs unknown to this multidata
|
||||||
if new_locations:
|
if new_locations:
|
||||||
if count_activity:
|
if count_activity:
|
||||||
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
|
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
|
sortable: list[tuple[int, int, int, int]] = []
|
||||||
for location in new_locations:
|
for location in new_locations:
|
||||||
item_id, target_player, flags = ctx.locations[slot][location]
|
# extract all fields to avoid runtime overhead in LocationStore
|
||||||
|
item_id, target_player, flags = slot_locations[location]
|
||||||
|
# sort/group by receiver and item
|
||||||
|
sortable.append((target_player, item_id, location, flags))
|
||||||
|
|
||||||
|
info_texts: list[dict[str, typing.Any]] = []
|
||||||
|
for target_player, item_id, location, flags in sorted(sortable):
|
||||||
new_item = NetworkItem(item_id, location, slot, flags)
|
new_item = NetworkItem(item_id, location, slot, flags)
|
||||||
send_items_to(ctx, team, target_player, new_item)
|
send_items_to(ctx, team, target_player, new_item)
|
||||||
|
|
||||||
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||||
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
|
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
|
||||||
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
|
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
|
||||||
info_text = json_format_send_event(new_item, target_player)
|
if len(info_texts) >= 140:
|
||||||
ctx.broadcast_team(team, [info_text])
|
# split into chunks that are close to compression window of 64K but not too big on the wire
|
||||||
|
# (roughly 1300-2600 bytes after compression depending on repetitiveness)
|
||||||
|
ctx.broadcast_team(team, info_texts)
|
||||||
|
info_texts.clear()
|
||||||
|
info_texts.append(json_format_send_event(new_item, target_player))
|
||||||
|
ctx.broadcast_team(team, info_texts)
|
||||||
|
del info_texts
|
||||||
|
del sortable
|
||||||
|
|
||||||
ctx.location_checks[team, slot] |= new_locations
|
ctx.location_checks[team, slot] |= new_locations
|
||||||
send_new_items(ctx)
|
send_new_items(ctx)
|
||||||
@@ -1050,14 +1170,20 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
|||||||
"hint_points": get_slot_points(ctx, team, slot),
|
"hint_points": get_slot_points(ctx, team, slot),
|
||||||
"checked_locations": new_locations, # send back new checks only
|
"checked_locations": new_locations, # send back new checks only
|
||||||
}])
|
}])
|
||||||
old_hints = ctx.hints[team, slot].copy()
|
updated_slots: typing.Set[tuple[int, int]] = set()
|
||||||
ctx.recheck_hints(team, slot)
|
ctx.recheck_hints(team, slot, updated_slots)
|
||||||
if old_hints != ctx.hints[team, slot]:
|
for hint_team, hint_slot in updated_slots:
|
||||||
ctx.on_changed_hints(team, slot)
|
ctx.on_changed_hints(hint_team, hint_slot)
|
||||||
ctx.save()
|
ctx.save()
|
||||||
|
|
||||||
|
|
||||||
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
|
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str],
|
||||||
|
status: HintStatus | None = None) -> typing.List[Hint]:
|
||||||
|
"""
|
||||||
|
Collect a new hint for a given item id or name, with a given status.
|
||||||
|
If status is None (which is the default value), an automatic status will be determined from the item's quality.
|
||||||
|
"""
|
||||||
|
|
||||||
hints = []
|
hints = []
|
||||||
slots: typing.Set[int] = {slot}
|
slots: typing.Set[int] = {slot}
|
||||||
for group_id, group in ctx.groups.items():
|
for group_id, group in ctx.groups.items():
|
||||||
@@ -1067,31 +1193,75 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
|||||||
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
||||||
for finding_player, location_id, item_id, receiving_player, item_flags \
|
for finding_player, location_id, item_id, receiving_player, item_flags \
|
||||||
in ctx.locations.find_item(slots, seeked_item_id):
|
in ctx.locations.find_item(slots, seeked_item_id):
|
||||||
found = location_id in ctx.location_checks[team, finding_player]
|
prev_hint = ctx.get_hint(team, finding_player, location_id)
|
||||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
if prev_hint:
|
||||||
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
hints.append(prev_hint)
|
||||||
item_flags))
|
else:
|
||||||
|
found = location_id in ctx.location_checks[team, finding_player]
|
||||||
|
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||||
|
|
||||||
|
hint_status = status # Assign again because we're in a for loop
|
||||||
|
if found:
|
||||||
|
hint_status = HintStatus.HINT_FOUND
|
||||||
|
elif hint_status is None:
|
||||||
|
if item_flags & ItemClassification.trap:
|
||||||
|
hint_status = HintStatus.HINT_AVOID
|
||||||
|
else:
|
||||||
|
hint_status = HintStatus.HINT_PRIORITY
|
||||||
|
|
||||||
|
hints.append(
|
||||||
|
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
|
||||||
|
)
|
||||||
|
|
||||||
return hints
|
return hints
|
||||||
|
|
||||||
|
|
||||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
|
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str,
|
||||||
|
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
|
||||||
|
"""
|
||||||
|
Collect a new hint for a given location name, with a given status (defaults to "unspecified").
|
||||||
|
If None is passed for the status, then an automatic status will be determined from the item's quality.
|
||||||
|
"""
|
||||||
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
|
||||||
return collect_hint_location_id(ctx, team, slot, seeked_location)
|
return collect_hint_location_id(ctx, team, slot, seeked_location, status)
|
||||||
|
|
||||||
|
|
||||||
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int) -> typing.List[NetUtils.Hint]:
|
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int,
|
||||||
|
status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
|
||||||
|
"""
|
||||||
|
Collect a new hint for a given location id, with a given status (defaults to "unspecified").
|
||||||
|
If None is passed for the status, then an automatic status will be determined from the item's quality.
|
||||||
|
"""
|
||||||
|
prev_hint = ctx.get_hint(team, slot, seeked_location)
|
||||||
|
if prev_hint:
|
||||||
|
return [prev_hint]
|
||||||
result = ctx.locations[slot].get(seeked_location, (None, None, None))
|
result = ctx.locations[slot].get(seeked_location, (None, None, None))
|
||||||
if any(result):
|
if any(result):
|
||||||
item_id, receiving_player, item_flags = result
|
item_id, receiving_player, item_flags = result
|
||||||
|
|
||||||
found = seeked_location in ctx.location_checks[team, slot]
|
found = seeked_location in ctx.location_checks[team, slot]
|
||||||
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
||||||
return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags)]
|
|
||||||
|
if found:
|
||||||
|
status = HintStatus.HINT_FOUND
|
||||||
|
elif status is None:
|
||||||
|
if item_flags & ItemClassification.trap:
|
||||||
|
status = HintStatus.HINT_AVOID
|
||||||
|
else:
|
||||||
|
status = HintStatus.HINT_PRIORITY
|
||||||
|
|
||||||
|
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags, status)]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
status_names: typing.Dict[HintStatus, str] = {
|
||||||
|
HintStatus.HINT_FOUND: "(found)",
|
||||||
|
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
|
||||||
|
HintStatus.HINT_NO_PRIORITY: "(no priority)",
|
||||||
|
HintStatus.HINT_AVOID: "(avoid)",
|
||||||
|
HintStatus.HINT_PRIORITY: "(priority)",
|
||||||
|
}
|
||||||
|
def format_hint(ctx: Context, team: int, hint: Hint) -> str:
|
||||||
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
|
||||||
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
|
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
|
||||||
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
|
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
|
||||||
@@ -1099,7 +1269,8 @@ def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
|||||||
|
|
||||||
if hint.entrance:
|
if hint.entrance:
|
||||||
text += f" at {hint.entrance}"
|
text += f" at {hint.entrance}"
|
||||||
return text + (". (found)" if hint.found else ".")
|
|
||||||
|
return text + ". " + status_names.get(hint.status, "(unknown)")
|
||||||
|
|
||||||
|
|
||||||
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
||||||
@@ -1193,7 +1364,8 @@ class CommandProcessor(metaclass=CommandMeta):
|
|||||||
argname += "=" + parameter.default
|
argname += "=" + parameter.default
|
||||||
argtext += argname
|
argtext += argname
|
||||||
argtext += " "
|
argtext += " "
|
||||||
s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n"
|
doctext = '\n '.join(inspect.getdoc(method).split('\n'))
|
||||||
|
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
|
||||||
return s
|
return s
|
||||||
|
|
||||||
def _cmd_help(self):
|
def _cmd_help(self):
|
||||||
@@ -1222,19 +1394,6 @@ class CommandProcessor(metaclass=CommandMeta):
|
|||||||
class CommonCommandProcessor(CommandProcessor):
|
class CommonCommandProcessor(CommandProcessor):
|
||||||
ctx: Context
|
ctx: Context
|
||||||
|
|
||||||
def _cmd_countdown(self, seconds: str = "10") -> bool:
|
|
||||||
"""Start a countdown in seconds"""
|
|
||||||
try:
|
|
||||||
timer = int(seconds, 10)
|
|
||||||
except ValueError:
|
|
||||||
timer = 10
|
|
||||||
else:
|
|
||||||
if timer > 60 * 60:
|
|
||||||
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
|
||||||
|
|
||||||
async_start(countdown(self.ctx, timer))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _cmd_options(self):
|
def _cmd_options(self):
|
||||||
"""List all current options. Warning: lists password."""
|
"""List all current options. Warning: lists password."""
|
||||||
self.output("Current options:")
|
self.output("Current options:")
|
||||||
@@ -1376,6 +1535,23 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
" You can ask the server admin for a /collect")
|
" You can ask the server admin for a /collect")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _cmd_countdown(self, seconds: str = "10") -> bool:
|
||||||
|
"""Start a countdown in seconds"""
|
||||||
|
if self.ctx.countdown_mode == "disabled" or \
|
||||||
|
self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30:
|
||||||
|
self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
timer = int(seconds, 10)
|
||||||
|
except ValueError:
|
||||||
|
timer = 10
|
||||||
|
else:
|
||||||
|
if timer > 60 * 60:
|
||||||
|
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
||||||
|
|
||||||
|
async_start(countdown(self.ctx, timer))
|
||||||
|
return True
|
||||||
|
|
||||||
def _cmd_remaining(self) -> bool:
|
def _cmd_remaining(self) -> bool:
|
||||||
"""List remaining items in your game, but not their location or recipient"""
|
"""List remaining items in your game, but not their location or recipient"""
|
||||||
if self.ctx.remaining_mode == "enabled":
|
if self.ctx.remaining_mode == "enabled":
|
||||||
@@ -1503,7 +1679,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
||||||
points_available = get_client_points(self.ctx, self.client)
|
points_available = get_client_points(self.ctx, self.client)
|
||||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||||
|
|
||||||
if not input_text:
|
if not input_text:
|
||||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||||
self.ctx.hints[self.client.team, self.client.slot]}
|
self.ctx.hints[self.client.team, self.client.slot]}
|
||||||
@@ -1558,7 +1733,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
hints = []
|
hints = []
|
||||||
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
||||||
if loc_name in self.ctx.location_names_for_game(game):
|
if loc_name in self.ctx.location_names_for_game(game):
|
||||||
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name))
|
hints.extend(
|
||||||
|
collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)
|
||||||
|
)
|
||||||
else: # location name
|
else: # location name
|
||||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||||
|
|
||||||
@@ -1725,7 +1902,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
ctx.clients[team][slot].append(client)
|
ctx.clients[team][slot].append(client)
|
||||||
client.version = args['version']
|
client.version = args['version']
|
||||||
client.tags = args['tags']
|
client.tags = args['tags']
|
||||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
client.no_locations = bool(client.tags & _non_game_messages.keys())
|
||||||
|
# set NoText for old PopTracker clients that predate the tag to save traffic
|
||||||
|
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
||||||
connected_packet = {
|
connected_packet = {
|
||||||
"cmd": "Connected",
|
"cmd": "Connected",
|
||||||
"team": client.team, "slot": client.slot,
|
"team": client.team, "slot": client.slot,
|
||||||
@@ -1797,7 +1976,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
old_tags = client.tags
|
old_tags = client.tags
|
||||||
client.tags = args["tags"]
|
client.tags = args["tags"]
|
||||||
if set(old_tags) != set(client.tags):
|
if set(old_tags) != set(client.tags):
|
||||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
client.no_locations = bool(client.tags & _non_game_messages.keys())
|
||||||
|
client.no_text = "NoText" in client.tags or (
|
||||||
|
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
||||||
|
)
|
||||||
ctx.broadcast_text_all(
|
ctx.broadcast_text_all(
|
||||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
|
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
|
||||||
f"from {old_tags} to {client.tags}.",
|
f"from {old_tags} to {client.tags}.",
|
||||||
@@ -1826,7 +2008,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
for location in args["locations"]:
|
for location in args["locations"]:
|
||||||
if type(location) is not int:
|
if type(location) is not int:
|
||||||
await ctx.send_msgs(client,
|
await ctx.send_msgs(client,
|
||||||
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
|
[{'cmd': 'InvalidPacket', "type": "arguments",
|
||||||
|
"text": 'Locations has to be a list of integers',
|
||||||
"original_cmd": cmd}])
|
"original_cmd": cmd}])
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1834,13 +2017,114 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
if create_as_hint:
|
if create_as_hint:
|
||||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
|
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
|
||||||
locs.append(NetworkItem(target_item, location, target_player, flags))
|
locs.append(NetworkItem(target_item, location, target_player, flags))
|
||||||
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
|
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
|
||||||
if locs and create_as_hint:
|
if locs and create_as_hint:
|
||||||
ctx.save()
|
ctx.save()
|
||||||
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
||||||
|
|
||||||
|
elif cmd == 'CreateHints':
|
||||||
|
location_player = args.get("player", client.slot)
|
||||||
|
locations = args["locations"]
|
||||||
|
status = args.get("status", HintStatus.HINT_UNSPECIFIED)
|
||||||
|
|
||||||
|
if not locations:
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = HintStatus(status)
|
||||||
|
except ValueError as err:
|
||||||
|
await ctx.send_msgs(client,
|
||||||
|
[{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": f"Unknown Status: {err}",
|
||||||
|
"original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
hints = []
|
||||||
|
|
||||||
|
for location in locations:
|
||||||
|
if location_player != client.slot and location not in ctx.locations[location_player]:
|
||||||
|
error_text = (
|
||||||
|
"CreateHints: One or more of the locations do not exist for the specified off-world player. "
|
||||||
|
"Please refrain from hinting other slot's locations that you don't know contain your items."
|
||||||
|
)
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": error_text, "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
target_item, item_player, flags = ctx.locations[location_player][location]
|
||||||
|
|
||||||
|
if client.slot not in ctx.slot_set(item_player):
|
||||||
|
if status != HintStatus.HINT_UNSPECIFIED:
|
||||||
|
error_text = 'CreateHints: Must use "unspecified"/None status for items from other players.'
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": error_text, "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
if client.slot != location_player:
|
||||||
|
error_text = "CreateHints: Can only create hints for own items or own locations."
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": error_text, "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
hints += collect_hint_location_id(ctx, client.team, location_player, location, status)
|
||||||
|
|
||||||
|
# As of writing this code, only_new=True does not update status for existing hints
|
||||||
|
ctx.notify_hints(client.team, hints, only_new=True, persist_even_if_found=True)
|
||||||
|
ctx.save()
|
||||||
|
|
||||||
|
elif cmd == 'UpdateHint':
|
||||||
|
location = args["location"]
|
||||||
|
player = args["player"]
|
||||||
|
status = args["status"]
|
||||||
|
if not isinstance(player, int) or not isinstance(location, int) \
|
||||||
|
or (status is not None and not isinstance(status, int)):
|
||||||
|
await ctx.send_msgs(client,
|
||||||
|
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint',
|
||||||
|
"original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
hint = ctx.get_hint(client.team, player, location)
|
||||||
|
if not hint:
|
||||||
|
return # Ignored safely
|
||||||
|
if client.slot not in ctx.slot_set(hint.receiving_player):
|
||||||
|
await ctx.send_msgs(client,
|
||||||
|
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint: No Permission',
|
||||||
|
"original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
new_hint = hint
|
||||||
|
if status is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
status = HintStatus(status)
|
||||||
|
except ValueError:
|
||||||
|
await ctx.send_msgs(client,
|
||||||
|
[{'cmd': 'InvalidPacket', "type": "arguments",
|
||||||
|
"text": 'UpdateHint: Invalid Status', "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
if status == HintStatus.HINT_FOUND:
|
||||||
|
await ctx.send_msgs(client,
|
||||||
|
[{'cmd': 'InvalidPacket', "type": "arguments",
|
||||||
|
"text": 'UpdateHint: Cannot manually update status to "HINT_FOUND"', "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
new_hint = new_hint.re_prioritize(ctx, status)
|
||||||
|
if hint == new_hint:
|
||||||
|
return
|
||||||
|
|
||||||
|
concerning_slots = ctx.slot_set(hint.receiving_player) | {hint.finding_player}
|
||||||
|
for slot in concerning_slots:
|
||||||
|
ctx.replace_hint(client.team, slot, hint, new_hint)
|
||||||
|
ctx.save()
|
||||||
|
for slot in concerning_slots:
|
||||||
|
ctx.on_changed_hints(client.team, slot)
|
||||||
|
|
||||||
elif cmd == 'StatusUpdate':
|
elif cmd == 'StatusUpdate':
|
||||||
update_client_status(ctx, client, args["status"])
|
if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL:
|
||||||
|
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
|
||||||
|
"text": "Trackers can't register Goal Complete",
|
||||||
|
"original_cmd": cmd}])
|
||||||
|
else:
|
||||||
|
update_client_status(ctx, client, args["status"])
|
||||||
|
|
||||||
elif cmd == 'Say':
|
elif cmd == 'Say':
|
||||||
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
||||||
@@ -1886,12 +2170,13 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
args["cmd"] = "SetReply"
|
args["cmd"] = "SetReply"
|
||||||
value = ctx.stored_data.get(args["key"], args.get("default", 0))
|
value = ctx.stored_data.get(args["key"], args.get("default", 0))
|
||||||
args["original_value"] = copy.copy(value)
|
args["original_value"] = copy.copy(value)
|
||||||
|
args["slot"] = client.slot
|
||||||
for operation in args["operations"]:
|
for operation in args["operations"]:
|
||||||
func = modify_functions[operation["operation"]]
|
func = modify_functions[operation["operation"]]
|
||||||
value = func(value, operation["value"])
|
value = func(value, operation["value"])
|
||||||
ctx.stored_data[args["key"]] = args["value"] = value
|
ctx.stored_data[args["key"]] = args["value"] = value
|
||||||
targets = set(ctx.stored_data_notification_clients[args["key"]])
|
targets = set(ctx.stored_data_notification_clients[args["key"]])
|
||||||
if args.get("want_reply", True):
|
if args.get("want_reply", False):
|
||||||
targets.add(client)
|
targets.add(client)
|
||||||
if targets:
|
if targets:
|
||||||
ctx.broadcast(targets, [args])
|
ctx.broadcast(targets, [args])
|
||||||
@@ -2022,6 +2307,19 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
self.output(f"Could not find player {player_name} to collect")
|
self.output(f"Could not find player {player_name} to collect")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _cmd_countdown(self, seconds: str = "10") -> bool:
|
||||||
|
"""Start a countdown in seconds"""
|
||||||
|
try:
|
||||||
|
timer = int(seconds, 10)
|
||||||
|
except ValueError:
|
||||||
|
timer = 10
|
||||||
|
else:
|
||||||
|
if timer > 60 * 60:
|
||||||
|
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
||||||
|
|
||||||
|
async_start(countdown(self.ctx, timer))
|
||||||
|
return True
|
||||||
|
|
||||||
@mark_raw
|
@mark_raw
|
||||||
def _cmd_release(self, player_name: str) -> bool:
|
def _cmd_release(self, player_name: str) -> bool:
|
||||||
"""Send out the remaining items from a player to their intended recipients."""
|
"""Send out the remaining items from a player to their intended recipients."""
|
||||||
@@ -2214,6 +2512,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
elif value_type == str and option_name.endswith("password"):
|
elif value_type == str and option_name.endswith("password"):
|
||||||
def value_type(input_text: str):
|
def value_type(input_text: str):
|
||||||
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
|
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
|
||||||
|
elif option_name == "countdown_mode":
|
||||||
|
valid_values = {"enabled", "disabled", "auto"}
|
||||||
|
if option_value.lower() not in valid_values:
|
||||||
|
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
|
||||||
|
return False
|
||||||
elif value_type == str and option_name.endswith("mode"):
|
elif value_type == str and option_name.endswith("mode"):
|
||||||
valid_values = {"goal", "enabled", "disabled"}
|
valid_values = {"goal", "enabled", "disabled"}
|
||||||
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
|
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
|
||||||
@@ -2263,8 +2566,10 @@ async def console(ctx: Context):
|
|||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
|
from settings import get_settings
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
defaults = Utils.get_settings()["server_options"].as_dict()
|
defaults = get_settings().server_options.as_dict()
|
||||||
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
|
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
|
||||||
parser.add_argument('--host', default=defaults["host"])
|
parser.add_argument('--host', default=defaults["host"])
|
||||||
parser.add_argument('--port', default=defaults["port"], type=int)
|
parser.add_argument('--port', default=defaults["port"], type=int)
|
||||||
@@ -2276,6 +2581,8 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
|
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
|
||||||
parser.add_argument('--loglevel', default=defaults["loglevel"],
|
parser.add_argument('--loglevel', default=defaults["loglevel"],
|
||||||
choices=['debug', 'info', 'warning', 'error', 'critical'])
|
choices=['debug', 'info', 'warning', 'error', 'critical'])
|
||||||
|
parser.add_argument('--logtime', help="Add timestamps to STDOUT",
|
||||||
|
default=defaults["logtime"], action='store_true')
|
||||||
parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int)
|
parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int)
|
||||||
parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int)
|
parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int)
|
||||||
parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
|
parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
|
||||||
@@ -2297,6 +2604,13 @@ def parse_args() -> argparse.Namespace:
|
|||||||
goal: !collect can be used after goal completion
|
goal: !collect can be used after goal completion
|
||||||
auto-enabled: !collect is available and automatically triggered on goal completion
|
auto-enabled: !collect is available and automatically triggered on goal completion
|
||||||
''')
|
''')
|
||||||
|
parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?',
|
||||||
|
choices=['enabled', 'disabled', "auto"], help='''\
|
||||||
|
Select !countdown Accessibility. (default: %(default)s)
|
||||||
|
enabled: !countdown is always available
|
||||||
|
disabled: !countdown is never available
|
||||||
|
auto: !countdown is available for rooms with less than 30 players
|
||||||
|
''')
|
||||||
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
|
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
|
||||||
choices=['enabled', 'disabled', "goal"], help='''\
|
choices=['enabled', 'disabled', "goal"], help='''\
|
||||||
Select !remaining Accessibility. (default: %(default)s)
|
Select !remaining Accessibility. (default: %(default)s)
|
||||||
@@ -2356,11 +2670,13 @@ def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLConte
|
|||||||
|
|
||||||
|
|
||||||
async def main(args: argparse.Namespace):
|
async def main(args: argparse.Namespace):
|
||||||
Utils.init_logging("Server", loglevel=args.loglevel.lower())
|
Utils.init_logging(name="Server",
|
||||||
|
loglevel=args.loglevel.lower(),
|
||||||
|
add_timestamp=args.logtime)
|
||||||
|
|
||||||
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
||||||
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
||||||
args.remaining_mode,
|
args.countdown_mode, args.remaining_mode,
|
||||||
args.auto_shutdown, args.compatibility, args.log_network)
|
args.auto_shutdown, args.compatibility, args.log_network)
|
||||||
data_filename = args.multidata
|
data_filename = args.multidata
|
||||||
|
|
||||||
@@ -2395,7 +2711,13 @@ async def main(args: argparse.Namespace):
|
|||||||
|
|
||||||
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
|
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
|
||||||
|
|
||||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context)
|
ctx.server = websockets.serve(
|
||||||
|
functools.partial(server, ctx=ctx),
|
||||||
|
host=ctx.host,
|
||||||
|
port=ctx.port,
|
||||||
|
ssl=ssl_context,
|
||||||
|
extensions=[server_per_message_deflate_factory],
|
||||||
|
)
|
||||||
ip = args.host if args.host else Utils.get_public_ipv4()
|
ip = args.host if args.host else Utils.get_public_ipv4()
|
||||||
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
|
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
|
||||||
'No password' if not ctx.password else 'Password: %s' % ctx.password))
|
'No password' if not ctx.password else 'Password: %s' % ctx.password))
|
||||||
|
|||||||
125
NetUtils.py
125
NetUtils.py
@@ -1,15 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
import typing
|
import typing
|
||||||
import enum
|
import enum
|
||||||
import warnings
|
import warnings
|
||||||
from json import JSONEncoder, JSONDecoder
|
from json import JSONEncoder, JSONDecoder
|
||||||
|
|
||||||
import websockets
|
if typing.TYPE_CHECKING:
|
||||||
|
from websockets import WebSocketServerProtocol as ServerConnection
|
||||||
|
|
||||||
from Utils import ByValue, Version
|
from Utils import ByValue, Version
|
||||||
|
|
||||||
|
|
||||||
|
class HintStatus(ByValue, enum.IntEnum):
|
||||||
|
HINT_UNSPECIFIED = 0
|
||||||
|
HINT_NO_PRIORITY = 10
|
||||||
|
HINT_AVOID = 20
|
||||||
|
HINT_PRIORITY = 30
|
||||||
|
HINT_FOUND = 40
|
||||||
|
|
||||||
|
|
||||||
class JSONMessagePart(typing.TypedDict, total=False):
|
class JSONMessagePart(typing.TypedDict, total=False):
|
||||||
text: str
|
text: str
|
||||||
# optional
|
# optional
|
||||||
@@ -19,6 +29,8 @@ class JSONMessagePart(typing.TypedDict, total=False):
|
|||||||
player: int
|
player: int
|
||||||
# if type == item indicates item flags
|
# if type == item indicates item flags
|
||||||
flags: int
|
flags: int
|
||||||
|
# if type == hint_status
|
||||||
|
hint_status: HintStatus
|
||||||
|
|
||||||
|
|
||||||
class ClientStatus(ByValue, enum.IntEnum):
|
class ClientStatus(ByValue, enum.IntEnum):
|
||||||
@@ -72,7 +84,7 @@ class NetworkSlot(typing.NamedTuple):
|
|||||||
name: str
|
name: str
|
||||||
game: str
|
game: str
|
||||||
type: SlotType
|
type: SlotType
|
||||||
group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
|
group_members: Sequence[int] = () # only populated if type == group
|
||||||
|
|
||||||
|
|
||||||
class NetworkItem(typing.NamedTuple):
|
class NetworkItem(typing.NamedTuple):
|
||||||
@@ -95,6 +107,27 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"]
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_base_types(obj: typing.Any) -> _base_types:
|
||||||
|
if isinstance(obj, (tuple, list, set, frozenset)):
|
||||||
|
return tuple(convert_to_base_types(o) for o in obj)
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()}
|
||||||
|
elif obj is None or type(obj) in (str, int, float, bool):
|
||||||
|
return obj
|
||||||
|
# unwrap simple types to their base, such as StrEnum
|
||||||
|
elif isinstance(obj, str):
|
||||||
|
return str(obj)
|
||||||
|
elif isinstance(obj, int):
|
||||||
|
return int(obj)
|
||||||
|
elif isinstance(obj, float):
|
||||||
|
return float(obj)
|
||||||
|
else:
|
||||||
|
raise Exception(f"Cannot handle {type(obj)}")
|
||||||
|
|
||||||
|
|
||||||
_encode = JSONEncoder(
|
_encode = JSONEncoder(
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
check_circular=False,
|
check_circular=False,
|
||||||
@@ -141,7 +174,9 @@ decode = JSONDecoder(object_hook=_object_hook).decode
|
|||||||
|
|
||||||
|
|
||||||
class Endpoint:
|
class Endpoint:
|
||||||
socket: websockets.WebSocketServerProtocol
|
__slots__ = ("socket",)
|
||||||
|
|
||||||
|
socket: "ServerConnection"
|
||||||
|
|
||||||
def __init__(self, socket):
|
def __init__(self, socket):
|
||||||
self.socket = socket
|
self.socket = socket
|
||||||
@@ -184,6 +219,7 @@ class JSONTypes(str, enum.Enum):
|
|||||||
location_name = "location_name"
|
location_name = "location_name"
|
||||||
location_id = "location_id"
|
location_id = "location_id"
|
||||||
entrance_name = "entrance_name"
|
entrance_name = "entrance_name"
|
||||||
|
hint_status = "hint_status"
|
||||||
|
|
||||||
|
|
||||||
class JSONtoTextParser(metaclass=HandlerMeta):
|
class JSONtoTextParser(metaclass=HandlerMeta):
|
||||||
@@ -224,7 +260,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
|||||||
|
|
||||||
def _handle_player_id(self, node: JSONMessagePart):
|
def _handle_player_id(self, node: JSONMessagePart):
|
||||||
player = int(node["text"])
|
player = int(node["text"])
|
||||||
node["color"] = 'magenta' if player == self.ctx.slot else 'yellow'
|
node["color"] = 'magenta' if self.ctx.slot_concerns_self(player) else 'yellow'
|
||||||
node["text"] = self.ctx.player_names[player]
|
node["text"] = self.ctx.player_names[player]
|
||||||
return self._handle_color(node)
|
return self._handle_color(node)
|
||||||
|
|
||||||
@@ -265,6 +301,10 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
|||||||
node["color"] = 'blue'
|
node["color"] = 'blue'
|
||||||
return self._handle_color(node)
|
return self._handle_color(node)
|
||||||
|
|
||||||
|
def _handle_hint_status(self, node: JSONMessagePart):
|
||||||
|
node["color"] = status_colors.get(node["hint_status"], "red")
|
||||||
|
return self._handle_color(node)
|
||||||
|
|
||||||
|
|
||||||
class RawJSONtoTextParser(JSONtoTextParser):
|
class RawJSONtoTextParser(JSONtoTextParser):
|
||||||
def _handle_color(self, node: JSONMessagePart):
|
def _handle_color(self, node: JSONMessagePart):
|
||||||
@@ -297,6 +337,27 @@ def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs)
|
|||||||
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
|
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
|
||||||
|
|
||||||
|
|
||||||
|
status_names: typing.Dict[HintStatus, str] = {
|
||||||
|
HintStatus.HINT_FOUND: "(found)",
|
||||||
|
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
|
||||||
|
HintStatus.HINT_NO_PRIORITY: "(no priority)",
|
||||||
|
HintStatus.HINT_AVOID: "(avoid)",
|
||||||
|
HintStatus.HINT_PRIORITY: "(priority)",
|
||||||
|
}
|
||||||
|
status_colors: typing.Dict[HintStatus, str] = {
|
||||||
|
HintStatus.HINT_FOUND: "green",
|
||||||
|
HintStatus.HINT_UNSPECIFIED: "white",
|
||||||
|
HintStatus.HINT_NO_PRIORITY: "slateblue",
|
||||||
|
HintStatus.HINT_AVOID: "salmon",
|
||||||
|
HintStatus.HINT_PRIORITY: "plum",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_json_hint_status(parts: list, hint_status: HintStatus, text: typing.Optional[str] = None, **kwargs):
|
||||||
|
parts.append({"text": text if text != None else status_names.get(hint_status, "(unknown)"),
|
||||||
|
"hint_status": hint_status, "type": JSONTypes.hint_status, **kwargs})
|
||||||
|
|
||||||
|
|
||||||
class Hint(typing.NamedTuple):
|
class Hint(typing.NamedTuple):
|
||||||
receiving_player: int
|
receiving_player: int
|
||||||
finding_player: int
|
finding_player: int
|
||||||
@@ -305,14 +366,21 @@ class Hint(typing.NamedTuple):
|
|||||||
found: bool
|
found: bool
|
||||||
entrance: str = ""
|
entrance: str = ""
|
||||||
item_flags: int = 0
|
item_flags: int = 0
|
||||||
|
status: HintStatus = HintStatus.HINT_UNSPECIFIED
|
||||||
|
|
||||||
def re_check(self, ctx, team) -> Hint:
|
def re_check(self, ctx, team) -> Hint:
|
||||||
if self.found:
|
if self.found and self.status == HintStatus.HINT_FOUND:
|
||||||
return self
|
return self
|
||||||
found = self.location in ctx.location_checks[team, self.finding_player]
|
found = self.location in ctx.location_checks[team, self.finding_player]
|
||||||
if found:
|
if found:
|
||||||
return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance,
|
return self._replace(found=found, status=HintStatus.HINT_FOUND)
|
||||||
self.item_flags)
|
return self
|
||||||
|
|
||||||
|
def re_prioritize(self, ctx, status: HintStatus) -> Hint:
|
||||||
|
if self.found and status != HintStatus.HINT_FOUND:
|
||||||
|
status = HintStatus.HINT_FOUND
|
||||||
|
if status != self.status:
|
||||||
|
return self._replace(status=status)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
@@ -334,10 +402,7 @@ class Hint(typing.NamedTuple):
|
|||||||
else:
|
else:
|
||||||
add_json_text(parts, "'s World")
|
add_json_text(parts, "'s World")
|
||||||
add_json_text(parts, ". ")
|
add_json_text(parts, ". ")
|
||||||
if self.found:
|
add_json_hint_status(parts, self.status)
|
||||||
add_json_text(parts, "(found)", type="color", color="green")
|
|
||||||
else:
|
|
||||||
add_json_text(parts, "(not found)", type="color", color="red")
|
|
||||||
|
|
||||||
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
|
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
|
||||||
"receiving": self.receiving_player,
|
"receiving": self.receiving_player,
|
||||||
@@ -383,6 +448,8 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
|||||||
checked = state[team, slot]
|
checked = state[team, slot]
|
||||||
if not checked:
|
if not checked:
|
||||||
# This optimizes the case where everyone connects to a fresh game at the same time.
|
# This optimizes the case where everyone connects to a fresh game at the same time.
|
||||||
|
if slot not in self:
|
||||||
|
raise KeyError(slot)
|
||||||
return []
|
return []
|
||||||
return [location_id for
|
return [location_id for
|
||||||
location_id in self[slot] if
|
location_id in self[slot] if
|
||||||
@@ -407,6 +474,42 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
|||||||
location_id not in checked])
|
location_id not in checked])
|
||||||
|
|
||||||
|
|
||||||
|
class MinimumVersions(typing.TypedDict):
|
||||||
|
server: tuple[int, int, int]
|
||||||
|
clients: dict[int, tuple[int, int, int]]
|
||||||
|
|
||||||
|
|
||||||
|
class GamesPackage(typing.TypedDict, total=False):
|
||||||
|
item_name_groups: dict[str, list[str]]
|
||||||
|
item_name_to_id: dict[str, int]
|
||||||
|
location_name_groups: dict[str, list[str]]
|
||||||
|
location_name_to_id: dict[str, int]
|
||||||
|
checksum: str
|
||||||
|
|
||||||
|
|
||||||
|
class DataPackage(typing.TypedDict):
|
||||||
|
games: dict[str, GamesPackage]
|
||||||
|
|
||||||
|
|
||||||
|
class MultiData(typing.TypedDict):
|
||||||
|
slot_data: dict[int, Mapping[str, typing.Any]]
|
||||||
|
slot_info: dict[int, NetworkSlot]
|
||||||
|
connect_names: dict[str, tuple[int, int]]
|
||||||
|
locations: dict[int, dict[int, tuple[int, int, int]]]
|
||||||
|
checks_in_area: dict[int, dict[str, int | list[int]]]
|
||||||
|
server_options: dict[str, object]
|
||||||
|
er_hint_data: dict[int, dict[int, str]]
|
||||||
|
precollected_items: dict[int, list[int]]
|
||||||
|
precollected_hints: dict[int, set[Hint]]
|
||||||
|
version: tuple[int, int, int]
|
||||||
|
tags: list[str]
|
||||||
|
minimum_versions: MinimumVersions
|
||||||
|
seed_name: str
|
||||||
|
spheres: list[dict[int, set[int]]]
|
||||||
|
datapackage: dict[str, GamesPackage]
|
||||||
|
race_mode: int
|
||||||
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
||||||
LocationStore = _LocationStore
|
LocationStore = _LocationStore
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import random
|
|
||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
@@ -197,7 +196,6 @@ def set_icon(window):
|
|||||||
def adjust(args):
|
def adjust(args):
|
||||||
# Create a fake multiworld and OOTWorld to use as a base
|
# Create a fake multiworld and OOTWorld to use as a base
|
||||||
multiworld = MultiWorld(1)
|
multiworld = MultiWorld(1)
|
||||||
multiworld.per_slot_randoms = {1: random}
|
|
||||||
ootworld = OOTWorld(multiworld, 1)
|
ootworld = OOTWorld(multiworld, 1)
|
||||||
# Set options in the fake OOTWorld
|
# Set options in the fake OOTWorld
|
||||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from CommonClient import CommonContext, server_loop, gui_enabled, \
|
|||||||
import Utils
|
import Utils
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from worlds import network_data_package
|
from worlds import network_data_package
|
||||||
|
from worlds.oot import OOTWorld
|
||||||
from worlds.oot.Rom import Rom, compress_rom_file
|
from worlds.oot.Rom import Rom, compress_rom_file
|
||||||
from worlds.oot.N64Patch import apply_patch_file
|
from worlds.oot.N64Patch import apply_patch_file
|
||||||
from worlds.oot.Utils import data_path
|
from worlds.oot.Utils import data_path
|
||||||
@@ -276,11 +277,12 @@ async def n64_sync_task(ctx: OoTContext):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
|
auto_start = OOTWorld.settings.rom_start
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
@@ -295,7 +297,7 @@ async def patch_and_run_game(apz5_file):
|
|||||||
decomp_path = base_name + '-decomp.z64'
|
decomp_path = base_name + '-decomp.z64'
|
||||||
comp_path = base_name + '.z64'
|
comp_path = base_name + '.z64'
|
||||||
# Load vanilla ROM, patch file, compress ROM
|
# Load vanilla ROM, patch file, compress ROM
|
||||||
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
|
rom_file_name = OOTWorld.settings.rom_file
|
||||||
rom = Rom(rom_file_name)
|
rom = Rom(rom_file_name)
|
||||||
|
|
||||||
sub_file = None
|
sub_file = None
|
||||||
@@ -346,7 +348,7 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
463
Options.py
463
Options.py
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
|
import collections
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
@@ -23,6 +24,12 @@ if typing.TYPE_CHECKING:
|
|||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
|
|
||||||
|
def roll_percentage(percentage: int | float) -> bool:
|
||||||
|
"""Roll a percentage chance.
|
||||||
|
percentage is expected to be in range [0, 100]"""
|
||||||
|
return random.random() < (float(percentage) / 100)
|
||||||
|
|
||||||
|
|
||||||
class OptionError(ValueError):
|
class OptionError(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -137,7 +144,7 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
If this is False, the docstring is instead interpreted as plain text, and
|
If this is False, the docstring is instead interpreted as plain text, and
|
||||||
displayed as-is on the WebHost with whitespace preserved.
|
displayed as-is on the WebHost with whitespace preserved.
|
||||||
|
|
||||||
If this is None, it inherits the value of `World.rich_text_options_doc`. For
|
If this is None, it inherits the value of `WebWorld.rich_text_options_doc`. For
|
||||||
backwards compatibility, this defaults to False, but worlds are encouraged to
|
backwards compatibility, this defaults to False, but worlds are encouraged to
|
||||||
set it to True and use reStructuredText for their Option documentation.
|
set it to True and use reStructuredText for their Option documentation.
|
||||||
|
|
||||||
@@ -487,6 +494,30 @@ class Choice(NumericOption):
|
|||||||
else:
|
else:
|
||||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||||
|
|
||||||
|
def __lt__(self, other: typing.Union[Choice, int, str]):
|
||||||
|
if isinstance(other, str):
|
||||||
|
assert other in self.options, f"compared against an unknown string. {self} < {other}"
|
||||||
|
other = self.options[other]
|
||||||
|
return super(Choice, self).__lt__(other)
|
||||||
|
|
||||||
|
def __gt__(self, other: typing.Union[Choice, int, str]):
|
||||||
|
if isinstance(other, str):
|
||||||
|
assert other in self.options, f"compared against an unknown string. {self} > {other}"
|
||||||
|
other = self.options[other]
|
||||||
|
return super(Choice, self).__gt__(other)
|
||||||
|
|
||||||
|
def __le__(self, other: typing.Union[Choice, int, str]):
|
||||||
|
if isinstance(other, str):
|
||||||
|
assert other in self.options, f"compared against an unknown string. {self} <= {other}"
|
||||||
|
other = self.options[other]
|
||||||
|
return super(Choice, self).__le__(other)
|
||||||
|
|
||||||
|
def __ge__(self, other: typing.Union[Choice, int, str]):
|
||||||
|
if isinstance(other, str):
|
||||||
|
assert other in self.options, f"compared against an unknown string. {self} >= {other}"
|
||||||
|
other = self.options[other]
|
||||||
|
return super(Choice, self).__ge__(other)
|
||||||
|
|
||||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||||
|
|
||||||
|
|
||||||
@@ -496,7 +527,7 @@ class TextChoice(Choice):
|
|||||||
|
|
||||||
def __init__(self, value: typing.Union[str, int]):
|
def __init__(self, value: typing.Union[str, int]):
|
||||||
assert isinstance(value, str) or isinstance(value, int), \
|
assert isinstance(value, str) or isinstance(value, int), \
|
||||||
f"{value} is not a valid option for {self.__class__.__name__}"
|
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -617,17 +648,17 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
|
|||||||
used_locations.append(location)
|
used_locations.append(location)
|
||||||
used_bosses.append(boss)
|
used_bosses.append(boss)
|
||||||
if not cls.valid_boss_name(boss):
|
if not cls.valid_boss_name(boss):
|
||||||
raise ValueError(f"{boss.title()} is not a valid boss name.")
|
raise ValueError(f"'{boss.title()}' is not a valid boss name.")
|
||||||
if not cls.valid_location_name(location):
|
if not cls.valid_location_name(location):
|
||||||
raise ValueError(f"{location.title()} is not a valid boss location name.")
|
raise ValueError(f"'{location.title()}' is not a valid boss location name.")
|
||||||
if not cls.can_place_boss(boss, location):
|
if not cls.can_place_boss(boss, location):
|
||||||
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
|
raise ValueError(f"'{location.title()}' is not a valid location for {boss.title()} to be placed.")
|
||||||
else:
|
else:
|
||||||
if cls.duplicate_bosses:
|
if cls.duplicate_bosses:
|
||||||
if not cls.valid_boss_name(option):
|
if not cls.valid_boss_name(option):
|
||||||
raise ValueError(f"{option} is not a valid boss name.")
|
raise ValueError(f"'{option}' is not a valid boss name.")
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"{option.title()} is not formatted correctly.")
|
raise ValueError(f"'{option.title()}' is not formatted correctly.")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def can_place_boss(cls, boss: str, location: str) -> bool:
|
def can_place_boss(cls, boss: str, location: str) -> bool:
|
||||||
@@ -657,6 +688,12 @@ class Range(NumericOption):
|
|||||||
range_start = 0
|
range_start = 0
|
||||||
range_end = 1
|
range_end = 1
|
||||||
|
|
||||||
|
_RANDOM_OPTS = [
|
||||||
|
"random", "random-low", "random-middle", "random-high",
|
||||||
|
"random-range-low-<min>-<max>", "random-range-middle-<min>-<max>",
|
||||||
|
"random-range-high-<min>-<max>", "random-range-<min>-<max>",
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(self, value: int):
|
def __init__(self, value: int):
|
||||||
if value < self.range_start:
|
if value < self.range_start:
|
||||||
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__}")
|
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__}")
|
||||||
@@ -682,16 +719,33 @@ class Range(NumericOption):
|
|||||||
# these are the conditions where "true" and "false" make sense
|
# these are the conditions where "true" and "false" make sense
|
||||||
if text == "true":
|
if text == "true":
|
||||||
return cls.from_any(cls.default)
|
return cls.from_any(cls.default)
|
||||||
else: # "false"
|
# "false"
|
||||||
return cls(0)
|
return cls(0)
|
||||||
return cls(int(text))
|
|
||||||
|
try:
|
||||||
|
num = int(text)
|
||||||
|
except ValueError:
|
||||||
|
# text is not a number
|
||||||
|
# Handle conditionally acceptable values here rather than in the f-string
|
||||||
|
default = ""
|
||||||
|
truefalse = ""
|
||||||
|
if hasattr(cls, "default"):
|
||||||
|
default = ", default"
|
||||||
|
if cls.range_start == 0 and cls.default != 0:
|
||||||
|
truefalse = ", \"true\", \"false\""
|
||||||
|
raise Exception(f"Invalid range value {text!r}. Acceptable values are: "
|
||||||
|
f"<int>{default}, high, low{truefalse}, "
|
||||||
|
f"{', '.join(cls._RANDOM_OPTS)}.")
|
||||||
|
|
||||||
|
return cls(num)
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def weighted_range(cls, text) -> Range:
|
def weighted_range(cls, text) -> Range:
|
||||||
if text == "random-low":
|
if text == "random-low":
|
||||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
|
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
|
||||||
elif text == "random-high":
|
elif text == "random-high":
|
||||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
|
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
|
||||||
elif text == "random-middle":
|
elif text == "random-middle":
|
||||||
return cls(cls.triangular(cls.range_start, cls.range_end))
|
return cls(cls.triangular(cls.range_start, cls.range_end))
|
||||||
elif text.startswith("random-range-"):
|
elif text.startswith("random-range-"):
|
||||||
@@ -700,9 +754,7 @@ class Range(NumericOption):
|
|||||||
return cls(random.randint(cls.range_start, cls.range_end))
|
return cls(random.randint(cls.range_start, cls.range_end))
|
||||||
else:
|
else:
|
||||||
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
|
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"Acceptable values are: {', '.join(cls._RANDOM_OPTS)}.")
|
||||||
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
|
|
||||||
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def custom_range(cls, text) -> Range:
|
def custom_range(cls, text) -> Range:
|
||||||
@@ -717,11 +769,11 @@ class Range(NumericOption):
|
|||||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||||
if text.startswith("random-range-low"):
|
if text.startswith("random-range-low"):
|
||||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
|
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
|
||||||
elif text.startswith("random-range-middle"):
|
elif text.startswith("random-range-middle"):
|
||||||
return cls(cls.triangular(random_range[0], random_range[1]))
|
return cls(cls.triangular(random_range[0], random_range[1]))
|
||||||
elif text.startswith("random-range-high"):
|
elif text.startswith("random-range-high"):
|
||||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
|
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
|
||||||
else:
|
else:
|
||||||
return cls(random.randint(random_range[0], random_range[1]))
|
return cls(random.randint(random_range[0], random_range[1]))
|
||||||
|
|
||||||
@@ -739,8 +791,16 @@ class Range(NumericOption):
|
|||||||
return str(self.value)
|
return str(self.value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
|
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
|
||||||
return int(round(random.triangular(lower, end, tri), 0))
|
"""
|
||||||
|
Integer triangular distribution for `lower` inclusive to `end` inclusive.
|
||||||
|
|
||||||
|
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
|
||||||
|
"""
|
||||||
|
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
|
||||||
|
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
|
||||||
|
# when a != b, so ensure the result is never more than `end`.
|
||||||
|
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
|
||||||
|
|
||||||
|
|
||||||
class NamedRange(Range):
|
class NamedRange(Range):
|
||||||
@@ -754,7 +814,7 @@ class NamedRange(Range):
|
|||||||
elif value > self.range_end and value not in self.special_range_names.values():
|
elif value > self.range_end and value not in self.special_range_names.values():
|
||||||
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
|
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
|
||||||
f"and is also not one of the supported named special values: {self.special_range_names}")
|
f"and is also not one of the supported named special values: {self.special_range_names}")
|
||||||
|
|
||||||
# See docstring
|
# See docstring
|
||||||
for key in self.special_range_names:
|
for key in self.special_range_names:
|
||||||
if key != key.lower():
|
if key != key.lower():
|
||||||
@@ -817,18 +877,21 @@ class VerifyKeys(metaclass=FreezeValidKeys):
|
|||||||
for item_name in self.value:
|
for item_name in self.value:
|
||||||
if item_name not in world.item_names:
|
if item_name not in world.item_names:
|
||||||
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
|
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
|
||||||
raise Exception(f"Item {item_name} from option {self} "
|
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)")
|
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||||
elif self.verify_location_name:
|
elif self.verify_location_name:
|
||||||
for location_name in self.value:
|
for location_name in self.value:
|
||||||
if location_name not in world.location_names:
|
if location_name not in world.location_names:
|
||||||
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
|
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
|
||||||
raise Exception(f"Location {location_name} from option {self} "
|
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)")
|
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||||
|
|
||||||
|
def __iter__(self) -> typing.Iterator[typing.Any]:
|
||||||
|
return self.value.__iter__()
|
||||||
|
|
||||||
|
|
||||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
|
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
|
||||||
default = {}
|
default = {}
|
||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
@@ -847,21 +910,57 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
|||||||
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
||||||
|
|
||||||
def __getitem__(self, item: str) -> typing.Any:
|
def __getitem__(self, item: str) -> typing.Any:
|
||||||
return self.value.__getitem__(item)
|
return self.value[item]
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[str]:
|
def __iter__(self) -> typing.Iterator[str]:
|
||||||
return self.value.__iter__()
|
return iter(self.value)
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self.value.__len__()
|
return len(self.value)
|
||||||
|
|
||||||
|
# __getitem__ fallback fails for Counters, so we define this explicitly
|
||||||
|
def __contains__(self, item) -> bool:
|
||||||
|
return item in self.value
|
||||||
|
|
||||||
|
|
||||||
class ItemDict(OptionDict):
|
class OptionCounter(OptionDict):
|
||||||
|
min: int | None = None
|
||||||
|
max: int | None = None
|
||||||
|
|
||||||
|
def __init__(self, value: dict[str, int]) -> None:
|
||||||
|
super(OptionCounter, self).__init__(collections.Counter(value))
|
||||||
|
|
||||||
|
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
|
||||||
|
super(OptionCounter, self).verify(world, player_name, plando_options)
|
||||||
|
|
||||||
|
range_errors = []
|
||||||
|
|
||||||
|
if self.max is not None:
|
||||||
|
range_errors += [
|
||||||
|
f"\"{key}: {value}\" is higher than maximum allowed value {self.max}."
|
||||||
|
for key, value in self.value.items() if value > self.max
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.min is not None:
|
||||||
|
range_errors += [
|
||||||
|
f"\"{key}: {value}\" is lower than minimum allowed value {self.min}."
|
||||||
|
for key, value in self.value.items() if value < self.min
|
||||||
|
]
|
||||||
|
|
||||||
|
if range_errors:
|
||||||
|
range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors
|
||||||
|
raise OptionError("\n".join(range_errors))
|
||||||
|
|
||||||
|
|
||||||
|
class ItemDict(OptionCounter):
|
||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
|
|
||||||
def __init__(self, value: typing.Dict[str, int]):
|
min = 0
|
||||||
if any(item_count < 1 for item_count in value.values()):
|
|
||||||
raise Exception("Cannot have non-positive item counts.")
|
def __init__(self, value: dict[str, int]) -> None:
|
||||||
|
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
|
||||||
|
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
|
||||||
|
|
||||||
super(ItemDict, self).__init__(value)
|
super(ItemDict, self).__init__(value)
|
||||||
|
|
||||||
|
|
||||||
@@ -940,6 +1039,8 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
supports_weighting = False
|
supports_weighting = False
|
||||||
display_name = "Plando Texts"
|
display_name = "Plando Texts"
|
||||||
|
|
||||||
|
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
|
||||||
|
|
||||||
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
|
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
|
||||||
self.value = list(deepcopy(value))
|
self.value = list(deepcopy(value))
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -971,7 +1072,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
if isinstance(data, typing.Iterable):
|
if isinstance(data, typing.Iterable):
|
||||||
for text in data:
|
for text in data:
|
||||||
if isinstance(text, typing.Mapping):
|
if isinstance(text, typing.Mapping):
|
||||||
if random.random() < float(text.get("percentage", 100)/100):
|
if roll_percentage(text.get("percentage", 100)):
|
||||||
at = text.get("at", None)
|
at = text.get("at", None)
|
||||||
if at is not None:
|
if at is not None:
|
||||||
if isinstance(at, dict):
|
if isinstance(at, dict):
|
||||||
@@ -997,7 +1098,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
else:
|
else:
|
||||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||||
elif isinstance(text, PlandoText):
|
elif isinstance(text, PlandoText):
|
||||||
if random.random() < float(text.percentage/100):
|
if roll_percentage(text.percentage):
|
||||||
texts.append(text)
|
texts.append(text)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
||||||
@@ -1013,10 +1114,10 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
yield from self.value
|
yield from self.value
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
|
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
|
||||||
return self.value.__getitem__(index)
|
return self.value[index]
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self.value.__len__()
|
return len(self.value)
|
||||||
|
|
||||||
|
|
||||||
class ConnectionsMeta(AssembleOptions):
|
class ConnectionsMeta(AssembleOptions):
|
||||||
@@ -1040,7 +1141,7 @@ class PlandoConnection(typing.NamedTuple):
|
|||||||
|
|
||||||
entrance: str
|
entrance: str
|
||||||
exit: str
|
exit: str
|
||||||
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
|
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped
|
||||||
percentage: int = 100
|
percentage: int = 100
|
||||||
|
|
||||||
|
|
||||||
@@ -1066,6 +1167,8 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
entrances: typing.ClassVar[typing.AbstractSet[str]]
|
entrances: typing.ClassVar[typing.AbstractSet[str]]
|
||||||
exits: typing.ClassVar[typing.AbstractSet[str]]
|
exits: typing.ClassVar[typing.AbstractSet[str]]
|
||||||
|
|
||||||
|
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
|
||||||
|
|
||||||
duplicate_exits: bool = False
|
duplicate_exits: bool = False
|
||||||
"""Whether or not exits should be allowed to be duplicate."""
|
"""Whether or not exits should be allowed to be duplicate."""
|
||||||
|
|
||||||
@@ -1106,11 +1209,11 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
used_entrances.append(entrance)
|
used_entrances.append(entrance)
|
||||||
used_exits.append(exit)
|
used_exits.append(exit)
|
||||||
if not cls.validate_entrance_name(entrance):
|
if not cls.validate_entrance_name(entrance):
|
||||||
raise ValueError(f"{entrance.title()} is not a valid entrance.")
|
raise ValueError(f"'{entrance.title()}' is not a valid entrance.")
|
||||||
if not cls.validate_exit_name(exit):
|
if not cls.validate_exit_name(exit):
|
||||||
raise ValueError(f"{exit.title()} is not a valid exit.")
|
raise ValueError(f"'{exit.title()}' is not a valid exit.")
|
||||||
if not cls.can_connect(entrance, exit):
|
if not cls.can_connect(entrance, exit):
|
||||||
raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.")
|
raise ValueError(f"Connection between '{entrance.title()}' and '{exit.title()}' is invalid.")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_any(cls, data: PlandoConFromAnyType) -> Self:
|
def from_any(cls, data: PlandoConFromAnyType) -> Self:
|
||||||
@@ -1121,7 +1224,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
for connection in data:
|
for connection in data:
|
||||||
if isinstance(connection, typing.Mapping):
|
if isinstance(connection, typing.Mapping):
|
||||||
percentage = connection.get("percentage", 100)
|
percentage = connection.get("percentage", 100)
|
||||||
if random.random() < float(percentage / 100):
|
if roll_percentage(percentage):
|
||||||
entrance = connection.get("entrance", None)
|
entrance = connection.get("entrance", None)
|
||||||
if is_iterable_except_str(entrance):
|
if is_iterable_except_str(entrance):
|
||||||
entrance = random.choice(sorted(entrance))
|
entrance = random.choice(sorted(entrance))
|
||||||
@@ -1139,7 +1242,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
percentage
|
percentage
|
||||||
))
|
))
|
||||||
elif isinstance(connection, PlandoConnection):
|
elif isinstance(connection, PlandoConnection):
|
||||||
if random.random() < float(connection.percentage / 100):
|
if roll_percentage(connection.percentage):
|
||||||
value.append(connection)
|
value.append(connection)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
||||||
@@ -1163,7 +1266,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
connection.exit) for connection in value])
|
connection.exit) for connection in value])
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
|
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
|
||||||
return self.value.__getitem__(index)
|
return self.value[index]
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[PlandoConnection]:
|
def __iter__(self) -> typing.Iterator[PlandoConnection]:
|
||||||
yield from self.value
|
yield from self.value
|
||||||
@@ -1175,7 +1278,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
class Accessibility(Choice):
|
class Accessibility(Choice):
|
||||||
"""
|
"""
|
||||||
Set rules for reachability of your items/locations.
|
Set rules for reachability of your items/locations.
|
||||||
|
|
||||||
**Full:** ensure everything can be reached and acquired.
|
**Full:** ensure everything can be reached and acquired.
|
||||||
|
|
||||||
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
||||||
@@ -1193,7 +1296,7 @@ class Accessibility(Choice):
|
|||||||
class ItemsAccessibility(Accessibility):
|
class ItemsAccessibility(Accessibility):
|
||||||
"""
|
"""
|
||||||
Set rules for reachability of your items/locations.
|
Set rules for reachability of your items/locations.
|
||||||
|
|
||||||
**Full:** ensure everything can be reached and acquired.
|
**Full:** ensure everything can be reached and acquired.
|
||||||
|
|
||||||
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
**Minimal:** ensure what is needed to reach your goal can be acquired.
|
||||||
@@ -1244,36 +1347,48 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
|||||||
progression_balancing: ProgressionBalancing
|
progression_balancing: ProgressionBalancing
|
||||||
accessibility: Accessibility
|
accessibility: Accessibility
|
||||||
|
|
||||||
def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
|
def as_dict(
|
||||||
|
self,
|
||||||
|
*option_names: str,
|
||||||
|
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
|
||||||
|
toggles_as_bools: bool = False,
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
"""
|
"""
|
||||||
Returns a dictionary of [str, Option.value]
|
Returns a dictionary of [str, Option.value]
|
||||||
|
|
||||||
:param option_names: names of the options to return
|
:param option_names: Names of the options to get the values of.
|
||||||
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
:param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
|
||||||
|
:param toggles_as_bools: Whether toggle options should be returned as bools instead of ints.
|
||||||
|
|
||||||
|
:return: A dictionary of each option name to the value of its Option. If the option is an OptionSet, the value
|
||||||
|
will be returned as a sorted list.
|
||||||
"""
|
"""
|
||||||
assert option_names, "options.as_dict() was used without any option names."
|
assert option_names, "options.as_dict() was used without any option names."
|
||||||
|
assert len(option_names) < len(self.__class__.type_hints), "Specify only options you need."
|
||||||
option_results = {}
|
option_results = {}
|
||||||
for option_name in option_names:
|
for option_name in option_names:
|
||||||
if option_name in type(self).type_hints:
|
if option_name not in type(self).type_hints:
|
||||||
if casing == "snake":
|
|
||||||
display_name = option_name
|
|
||||||
elif casing == "camel":
|
|
||||||
split_name = [name.title() for name in option_name.split("_")]
|
|
||||||
split_name[0] = split_name[0].lower()
|
|
||||||
display_name = "".join(split_name)
|
|
||||||
elif casing == "pascal":
|
|
||||||
display_name = "".join([name.title() for name in option_name.split("_")])
|
|
||||||
elif casing == "kebab":
|
|
||||||
display_name = option_name.replace("_", "-")
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{casing} is invalid casing for as_dict. "
|
|
||||||
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
|
|
||||||
value = getattr(self, option_name).value
|
|
||||||
if isinstance(value, set):
|
|
||||||
value = sorted(value)
|
|
||||||
option_results[display_name] = value
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
||||||
|
|
||||||
|
if casing == "snake":
|
||||||
|
display_name = option_name
|
||||||
|
elif casing == "camel":
|
||||||
|
split_name = [name.title() for name in option_name.split("_")]
|
||||||
|
split_name[0] = split_name[0].lower()
|
||||||
|
display_name = "".join(split_name)
|
||||||
|
elif casing == "pascal":
|
||||||
|
display_name = "".join([name.title() for name in option_name.split("_")])
|
||||||
|
elif casing == "kebab":
|
||||||
|
display_name = option_name.replace("_", "-")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"{casing} is invalid casing for as_dict. "
|
||||||
|
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
|
||||||
|
value = getattr(self, option_name).value
|
||||||
|
if isinstance(value, set):
|
||||||
|
value = sorted(value)
|
||||||
|
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
|
||||||
|
value = bool(value)
|
||||||
|
option_results[display_name] = value
|
||||||
return option_results
|
return option_results
|
||||||
|
|
||||||
|
|
||||||
@@ -1290,14 +1405,15 @@ class NonLocalItems(ItemSet):
|
|||||||
|
|
||||||
|
|
||||||
class StartInventory(ItemDict):
|
class StartInventory(ItemDict):
|
||||||
"""Start with these items."""
|
"""Start with the specified amount of these items. Example: "Bomb: 1" """
|
||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
display_name = "Start Inventory"
|
display_name = "Start Inventory"
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
|
max = 10000
|
||||||
|
|
||||||
|
|
||||||
class StartInventoryPool(StartInventory):
|
class StartInventoryPool(StartInventory):
|
||||||
"""Start with these items and don't place them in the world.
|
"""Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1"
|
||||||
|
|
||||||
The game decides what the replacement items will be.
|
The game decides what the replacement items will be.
|
||||||
"""
|
"""
|
||||||
@@ -1344,6 +1460,7 @@ class DeathLink(Toggle):
|
|||||||
class ItemLinks(OptionList):
|
class ItemLinks(OptionList):
|
||||||
"""Share part of your item pool with other players."""
|
"""Share part of your item pool with other players."""
|
||||||
display_name = "Item Links"
|
display_name = "Item Links"
|
||||||
|
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
default = []
|
default = []
|
||||||
schema = Schema([
|
schema = Schema([
|
||||||
@@ -1355,6 +1472,7 @@ class ItemLinks(OptionList):
|
|||||||
Optional("local_items"): [And(str, len)],
|
Optional("local_items"): [And(str, len)],
|
||||||
Optional("non_local_items"): [And(str, len)],
|
Optional("non_local_items"): [And(str, len)],
|
||||||
Optional("link_replacement"): Or(None, bool),
|
Optional("link_replacement"): Or(None, bool),
|
||||||
|
Optional("skip_if_solo"): Or(None, bool),
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1368,8 +1486,8 @@ class ItemLinks(OptionList):
|
|||||||
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), 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 ""
|
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} "
|
raise Exception(f"Item '{item_name}' from item link '{item_link}' "
|
||||||
f"is not a valid item from {world.game} for {pool_name}. "
|
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}")
|
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
|
||||||
if allow_item_groups:
|
if allow_item_groups:
|
||||||
pool |= world.item_name_groups.get(item_name, {item_name})
|
pool |= world.item_name_groups.get(item_name, {item_name})
|
||||||
@@ -1382,8 +1500,10 @@ class ItemLinks(OptionList):
|
|||||||
super(ItemLinks, self).verify(world, player_name, plando_options)
|
super(ItemLinks, self).verify(world, player_name, plando_options)
|
||||||
existing_links = set()
|
existing_links = set()
|
||||||
for link in self.value:
|
for link in self.value:
|
||||||
|
link["name"] = link["name"].strip()[:16].strip()
|
||||||
if link["name"] in existing_links:
|
if link["name"] in existing_links:
|
||||||
raise Exception(f"You cannot have more than one link named {link['name']}.")
|
raise Exception(f"Item link names are limited to their first 16 characters and must be unique. "
|
||||||
|
f"You have more than one link named '{link['name']}'.")
|
||||||
existing_links.add(link["name"])
|
existing_links.add(link["name"])
|
||||||
|
|
||||||
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
|
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
|
||||||
@@ -1409,6 +1529,134 @@ class ItemLinks(OptionList):
|
|||||||
link["item_pool"] = list(pool)
|
link["item_pool"] = list(pool)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlandoItem:
|
||||||
|
items: list[str] | dict[str, typing.Any]
|
||||||
|
locations: list[str]
|
||||||
|
world: int | str | bool | None | typing.Iterable[str] | set[int] = False
|
||||||
|
from_pool: bool = True
|
||||||
|
force: bool | typing.Literal["silent"] = "silent"
|
||||||
|
count: int | bool | dict[str, int] = False
|
||||||
|
percentage: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
class PlandoItems(Option[typing.List[PlandoItem]]):
|
||||||
|
"""Generic items plando."""
|
||||||
|
default = ()
|
||||||
|
supports_weighting = False
|
||||||
|
display_name = "Plando Items"
|
||||||
|
visibility = Visibility.template | Visibility.spoiler
|
||||||
|
|
||||||
|
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
|
||||||
|
self.value = list(deepcopy(value))
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
|
||||||
|
if not isinstance(data, typing.Iterable):
|
||||||
|
raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
|
||||||
|
|
||||||
|
value: typing.List[PlandoItem] = []
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, typing.Mapping):
|
||||||
|
percentage = item.get("percentage", 100)
|
||||||
|
if not isinstance(percentage, int):
|
||||||
|
raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
|
||||||
|
if not (0 <= percentage <= 100):
|
||||||
|
raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
|
||||||
|
if roll_percentage(percentage):
|
||||||
|
count = item.get("count", False)
|
||||||
|
items = item.get("items", [])
|
||||||
|
if not items:
|
||||||
|
items = item.get("item", None) # explicitly throw an error here if not present
|
||||||
|
if not items:
|
||||||
|
raise OptionError("You must specify at least one item to place items with plando.")
|
||||||
|
count = 1
|
||||||
|
if isinstance(items, str):
|
||||||
|
items = [items]
|
||||||
|
elif not isinstance(items, (dict, list)):
|
||||||
|
raise OptionError(f"Plando 'items' has to be string, list, or "
|
||||||
|
f"dictionary, not {type(items)}")
|
||||||
|
locations = item.get("locations", [])
|
||||||
|
if not locations:
|
||||||
|
locations = item.get("location", [])
|
||||||
|
if locations:
|
||||||
|
count = 1
|
||||||
|
else:
|
||||||
|
locations = ["Everywhere"]
|
||||||
|
if isinstance(locations, str):
|
||||||
|
locations = [locations]
|
||||||
|
if not isinstance(locations, list):
|
||||||
|
raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
|
||||||
|
world = item.get("world", False)
|
||||||
|
from_pool = item.get("from_pool", True)
|
||||||
|
force = item.get("force", "silent")
|
||||||
|
if not isinstance(from_pool, bool):
|
||||||
|
raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
|
||||||
|
if not (isinstance(force, bool) or force == "silent"):
|
||||||
|
raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
|
||||||
|
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
|
||||||
|
elif isinstance(item, PlandoItem):
|
||||||
|
if roll_percentage(item.percentage):
|
||||||
|
value.append(item)
|
||||||
|
else:
|
||||||
|
raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
|
||||||
|
return cls(value)
|
||||||
|
|
||||||
|
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||||
|
if not self.value:
|
||||||
|
return
|
||||||
|
from BaseClasses import PlandoOptions
|
||||||
|
if not (PlandoOptions.items & plando_options):
|
||||||
|
# plando is disabled but plando options were given so overwrite the options
|
||||||
|
self.value = []
|
||||||
|
logging.warning(f"The plando items module is turned off, "
|
||||||
|
f"so items for {player_name} will be ignored.")
|
||||||
|
else:
|
||||||
|
# filter down item groups
|
||||||
|
for plando in self.value:
|
||||||
|
# confirm a valid count
|
||||||
|
if isinstance(plando.count, dict):
|
||||||
|
if "min" in plando.count and "max" in plando.count:
|
||||||
|
if plando.count["min"] > plando.count["max"]:
|
||||||
|
raise OptionError("Plando cannot have count `min` greater than `max`.")
|
||||||
|
items_copy = plando.items.copy()
|
||||||
|
if isinstance(plando.items, dict):
|
||||||
|
for item in items_copy:
|
||||||
|
if item in world.item_name_groups:
|
||||||
|
value = plando.items.pop(item)
|
||||||
|
group = world.item_name_groups[item]
|
||||||
|
filtered_items = sorted(group.difference(list(plando.items.keys())))
|
||||||
|
if not filtered_items:
|
||||||
|
raise OptionError(f"Plando `items` contains the group \"{item}\" "
|
||||||
|
f"and every item in it. This is not allowed.")
|
||||||
|
if value is True:
|
||||||
|
for key in filtered_items:
|
||||||
|
plando.items[key] = True
|
||||||
|
else:
|
||||||
|
for key in random.choices(filtered_items, k=value):
|
||||||
|
plando.items[key] = plando.items.get(key, 0) + 1
|
||||||
|
else:
|
||||||
|
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
|
||||||
|
for item in items_copy:
|
||||||
|
if item in world.item_name_groups:
|
||||||
|
plando.items.remove(item)
|
||||||
|
plando.items.extend(sorted(world.item_name_groups[item]))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_option_name(cls, value: list[PlandoItem]) -> str:
|
||||||
|
return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
|
||||||
|
|
||||||
|
def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
|
||||||
|
return self.value.__getitem__(index)
|
||||||
|
|
||||||
|
def __iter__(self) -> typing.Iterator[PlandoItem]:
|
||||||
|
yield from self.value
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.value)
|
||||||
|
|
||||||
|
|
||||||
class Removed(FreeText):
|
class Removed(FreeText):
|
||||||
"""This Option has been Removed."""
|
"""This Option has been Removed."""
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
@@ -1431,6 +1679,7 @@ class PerGameCommonOptions(CommonOptions):
|
|||||||
exclude_locations: ExcludeLocations
|
exclude_locations: ExcludeLocations
|
||||||
priority_locations: PriorityLocations
|
priority_locations: PriorityLocations
|
||||||
item_links: ItemLinks
|
item_links: ItemLinks
|
||||||
|
plando_items: PlandoItems
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -1449,7 +1698,7 @@ class OptionGroup(typing.NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
|
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
|
||||||
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks]
|
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks, PlandoItems]
|
||||||
"""
|
"""
|
||||||
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
|
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
|
||||||
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
|
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
|
||||||
@@ -1460,26 +1709,31 @@ it.
|
|||||||
def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[
|
def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[
|
||||||
str, typing.Dict[str, typing.Type[Option[typing.Any]]]]:
|
str, typing.Dict[str, typing.Type[Option[typing.Any]]]]:
|
||||||
"""Generates and returns a dictionary for the option groups of a specified world."""
|
"""Generates and returns a dictionary for the option groups of a specified world."""
|
||||||
option_groups = {option: option_group.name
|
option_to_name = {option: option_name for option_name, option in world.options_dataclass.type_hints.items()}
|
||||||
for option_group in world.web.option_groups
|
|
||||||
for option in option_group.options}
|
ordered_groups = {group.name: group.options for group in world.web.option_groups}
|
||||||
|
|
||||||
# add a default option group for uncategorized options to get thrown into
|
# add a default option group for uncategorized options to get thrown into
|
||||||
ordered_groups = ["Game Options"]
|
if "Game Options" not in ordered_groups:
|
||||||
[ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups]
|
grouped_options = set(option for group in ordered_groups.values() for option in group)
|
||||||
grouped_options = {group: {} for group in ordered_groups}
|
ungrouped_options = [option for option in option_to_name if option not in grouped_options]
|
||||||
for option_name, option in world.options_dataclass.type_hints.items():
|
# only add the game options group if we have ungrouped options
|
||||||
if visibility_level & option.visibility:
|
if ungrouped_options:
|
||||||
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
|
ordered_groups = {**{"Game Options": ungrouped_options}, **ordered_groups}
|
||||||
|
|
||||||
# if the world doesn't have any ungrouped options, this group will be empty so just remove it
|
return {
|
||||||
if not grouped_options["Game Options"]:
|
group: {
|
||||||
del grouped_options["Game Options"]
|
option_to_name[option]: option
|
||||||
|
for option in group_options
|
||||||
return grouped_options
|
if (visibility_level in option.visibility and option in option_to_name)
|
||||||
|
}
|
||||||
|
for group, group_options in ordered_groups.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
|
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
|
||||||
import os
|
import os
|
||||||
|
from inspect import cleandoc
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
@@ -1499,11 +1753,16 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
|
|
||||||
def dictify_range(option: Range):
|
def dictify_range(option: Range):
|
||||||
data = {option.default: 50}
|
data = {option.default: 50}
|
||||||
for sub_option in ["random", "random-low", "random-high"]:
|
for sub_option in ["random", "random-low", "random-high",
|
||||||
|
f"random-range-{option.range_start}-{option.range_end}"]:
|
||||||
if sub_option != option.default:
|
if sub_option != option.default:
|
||||||
data[sub_option] = 0
|
data[sub_option] = 0
|
||||||
|
notes = {
|
||||||
notes = {}
|
"random-low": "random value weighted towards lower values",
|
||||||
|
"random-high": "random value weighted towards higher values",
|
||||||
|
f"random-range-{option.range_start}-{option.range_end}": f"random value between "
|
||||||
|
f"{option.range_start} and {option.range_end}"
|
||||||
|
}
|
||||||
for name, number in getattr(option, "special_range_names", {}).items():
|
for name, number in getattr(option, "special_range_names", {}).items():
|
||||||
notes[name] = f"equivalent to {number}"
|
notes[name] = f"equivalent to {number}"
|
||||||
if number in data:
|
if number in data:
|
||||||
@@ -1518,18 +1777,23 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
# yaml dump may add end of document marker and newlines.
|
# yaml dump may add end of document marker and newlines.
|
||||||
return yaml.dump(scalar).replace("...\n", "").strip()
|
return yaml.dump(scalar).replace("...\n", "").strip()
|
||||||
|
|
||||||
|
with open(local_path("data", "options.yaml")) as f:
|
||||||
|
file_data = f.read()
|
||||||
|
template = Template(file_data)
|
||||||
|
|
||||||
for game_name, world in AutoWorldRegister.world_types.items():
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
if not world.hidden or generate_hidden:
|
if not world.hidden or generate_hidden:
|
||||||
option_groups = get_option_groups(world)
|
option_groups = get_option_groups(world)
|
||||||
with open(local_path("data", "options.yaml")) as f:
|
|
||||||
file_data = f.read()
|
|
||||||
res = Template(file_data).render(
|
|
||||||
option_groups=option_groups,
|
|
||||||
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
|
||||||
dictify_range=dictify_range,
|
|
||||||
)
|
|
||||||
|
|
||||||
del file_data
|
res = template.render(
|
||||||
|
option_groups=option_groups,
|
||||||
|
__version__=__version__,
|
||||||
|
game=game_name,
|
||||||
|
world_version=world.world_version.as_simple_string(),
|
||||||
|
yaml_dump=yaml_dump_scalar,
|
||||||
|
dictify_range=dictify_range,
|
||||||
|
cleandoc=cleandoc,
|
||||||
|
)
|
||||||
|
|
||||||
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
|
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||||
f.write(res)
|
f.write(res)
|
||||||
@@ -1556,10 +1820,11 @@ def dump_player_options(multiworld: MultiWorld) -> None:
|
|||||||
player_output = {
|
player_output = {
|
||||||
"Game": multiworld.game[player],
|
"Game": multiworld.game[player],
|
||||||
"Name": multiworld.get_player_name(player),
|
"Name": multiworld.get_player_name(player),
|
||||||
|
"ID": player,
|
||||||
}
|
}
|
||||||
output.append(player_output)
|
output.append(player_output)
|
||||||
for option_key, option in world.options_dataclass.type_hints.items():
|
for option_key, option in world.options_dataclass.type_hints.items():
|
||||||
if issubclass(Removed, option):
|
if option.visibility == Visibility.none:
|
||||||
continue
|
continue
|
||||||
display_name = getattr(option, "display_name", option_key)
|
display_name = getattr(option, "display_name", option_key)
|
||||||
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
||||||
@@ -1568,7 +1833,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
|
|||||||
game_option_names.append(display_name)
|
game_option_names.append(display_name)
|
||||||
|
|
||||||
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
|
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
|
||||||
fields = ["Game", "Name", *all_option_names]
|
fields = ["ID", "Game", "Name", *all_option_names]
|
||||||
writer = DictWriter(file, fields)
|
writer = DictWriter(file, fields)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
writer.writerows(output)
|
writer.writerows(output)
|
||||||
|
|||||||
674
OptionsCreator.py
Normal file
674
OptionsCreator.py
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
if __name__ == "__main__":
|
||||||
|
import ModuleUpdate
|
||||||
|
|
||||||
|
ModuleUpdate.update()
|
||||||
|
|
||||||
|
|
||||||
|
from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel,
|
||||||
|
ToggleButton, MarkupDropdown, ResizableTextField)
|
||||||
|
from kivy.uix.behaviors.button import ButtonBehavior
|
||||||
|
from kivymd.uix.behaviors import RotateBehavior
|
||||||
|
from kivymd.uix.anchorlayout import MDAnchorLayout
|
||||||
|
from kivymd.uix.expansionpanel import MDExpansionPanel, MDExpansionPanelContent, MDExpansionPanelHeader
|
||||||
|
from kivymd.uix.list import MDListItem, MDListItemTrailingIcon, MDListItemSupportingText
|
||||||
|
from kivymd.uix.slider import MDSlider
|
||||||
|
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
|
||||||
|
from kivymd.uix.menu import MDDropdownMenu
|
||||||
|
from kivymd.uix.button import MDButton, MDButtonText, MDIconButton
|
||||||
|
from kivymd.uix.dialog import MDDialog
|
||||||
|
from kivy.core.text.markup import MarkupLabel
|
||||||
|
from kivy.utils import escape_markup
|
||||||
|
from kivy.lang.builder import Builder
|
||||||
|
from kivy.properties import ObjectProperty
|
||||||
|
from textwrap import dedent
|
||||||
|
from copy import deepcopy
|
||||||
|
import Utils
|
||||||
|
import typing
|
||||||
|
import webbrowser
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister, World
|
||||||
|
from Options import (Option, Toggle, TextChoice, Choice, FreeText, NamedRange, Range, OptionSet, OptionList, Removed,
|
||||||
|
OptionCounter, Visibility)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url(x):
|
||||||
|
try:
|
||||||
|
result = urlparse(x)
|
||||||
|
return all([result.scheme, result.netloc])
|
||||||
|
except AttributeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def filter_tooltip(tooltip):
|
||||||
|
if tooltip is None:
|
||||||
|
tooltip = "No tooltip available."
|
||||||
|
tooltip = dedent(tooltip).strip().replace("\n", "<br>").replace("&", "&") \
|
||||||
|
.replace("[", "&bl;").replace("]", "&br;")
|
||||||
|
tooltip = re.sub(r"\*\*(.+?)\*\*", r"[b]\g<1>[/b]", tooltip)
|
||||||
|
tooltip = re.sub(r"\*(.+?)\*", r"[i]\g<1>[/i]", tooltip)
|
||||||
|
return escape_markup(tooltip)
|
||||||
|
|
||||||
|
|
||||||
|
def option_can_be_randomized(option: typing.Type[Option]):
|
||||||
|
# most options can be randomized, so we should just check for those that cannot
|
||||||
|
if not option.supports_weighting:
|
||||||
|
return False
|
||||||
|
elif issubclass(option, FreeText) and not issubclass(option, TextChoice):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_random(value: typing.Any):
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value # cannot be random if evaluated
|
||||||
|
if value.startswith("random-"):
|
||||||
|
return "random"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class TrailingPressedIconButton(ButtonBehavior, RotateBehavior, MDListItemTrailingIcon):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WorldButton(ToggleButton):
|
||||||
|
world_cls: typing.Type[World]
|
||||||
|
|
||||||
|
|
||||||
|
class VisualRange(MDBoxLayout):
|
||||||
|
option: typing.Type[Range]
|
||||||
|
name: str
|
||||||
|
tag: MDLabel = ObjectProperty(None)
|
||||||
|
slider: MDSlider = ObjectProperty(None)
|
||||||
|
|
||||||
|
def __init__(self, *args, option: typing.Type[Range], name: str, **kwargs):
|
||||||
|
self.option = option
|
||||||
|
self.name = name
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def update_points(*update_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.slider._update_points = update_points
|
||||||
|
|
||||||
|
|
||||||
|
class VisualChoice(MDButton):
|
||||||
|
option: typing.Type[Choice]
|
||||||
|
name: str
|
||||||
|
text: MDButtonText = ObjectProperty(None)
|
||||||
|
|
||||||
|
def __init__(self, *args, option: typing.Type[Choice], name: str, **kwargs):
|
||||||
|
self.option = option
|
||||||
|
self.name = name
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class VisualNamedRange(MDBoxLayout):
|
||||||
|
option: typing.Type[NamedRange]
|
||||||
|
name: str
|
||||||
|
range: VisualRange = ObjectProperty(None)
|
||||||
|
choice: MDButton = ObjectProperty(None)
|
||||||
|
|
||||||
|
def __init__(self, *args, option: typing.Type[NamedRange], name: str, range_widget: VisualRange, **kwargs):
|
||||||
|
self.option = option
|
||||||
|
self.name = name
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.range = range_widget
|
||||||
|
self.add_widget(self.range)
|
||||||
|
|
||||||
|
|
||||||
|
class VisualFreeText(ResizableTextField):
|
||||||
|
option: typing.Type[FreeText] | typing.Type[TextChoice]
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def __init__(self, *args, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str, **kwargs):
|
||||||
|
self.option = option
|
||||||
|
self.name = name
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class VisualTextChoice(MDBoxLayout):
|
||||||
|
option: typing.Type[TextChoice]
|
||||||
|
name: str
|
||||||
|
choice: VisualChoice = ObjectProperty(None)
|
||||||
|
text: VisualFreeText = ObjectProperty(None)
|
||||||
|
|
||||||
|
def __init__(self, *args, option: typing.Type[TextChoice], name: str, choice: VisualChoice,
|
||||||
|
text: VisualFreeText, **kwargs):
|
||||||
|
self.option = option
|
||||||
|
self.name = name
|
||||||
|
super(MDBoxLayout, self).__init__(*args, **kwargs)
|
||||||
|
self.choice = choice
|
||||||
|
self.text = text
|
||||||
|
self.add_widget(self.choice)
|
||||||
|
self.add_widget(self.text)
|
||||||
|
|
||||||
|
|
||||||
|
class VisualToggle(MDBoxLayout):
|
||||||
|
button: MDIconButton = ObjectProperty(None)
|
||||||
|
option: typing.Type[Toggle]
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def __init__(self, *args, option: typing.Type[Toggle], name: str, **kwargs):
|
||||||
|
self.option = option
|
||||||
|
self.name = name
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class CounterItemValue(ResizableTextField):
|
||||||
|
pat = re.compile('[^0-9]')
|
||||||
|
|
||||||
|
def insert_text(self, substring, from_undo=False):
|
||||||
|
return super().insert_text(re.sub(self.pat, "", substring), from_undo=from_undo)
|
||||||
|
|
||||||
|
|
||||||
|
class VisualListSetCounter(MDDialog):
|
||||||
|
button: MDIconButton = ObjectProperty(None)
|
||||||
|
option: typing.Type[OptionSet] | typing.Type[OptionList] | typing.Type[OptionCounter]
|
||||||
|
scrollbox: ScrollBox = ObjectProperty(None)
|
||||||
|
add: MDIconButton = ObjectProperty(None)
|
||||||
|
save: MDButton = ObjectProperty(None)
|
||||||
|
input: ResizableTextField = ObjectProperty(None)
|
||||||
|
dropdown: MDDropdownMenu
|
||||||
|
valid_keys: typing.Iterable[str]
|
||||||
|
|
||||||
|
def __init__(self, *args, option: typing.Type[OptionSet] | typing.Type[OptionList],
|
||||||
|
name: str, valid_keys: typing.Iterable[str], **kwargs):
|
||||||
|
self.option = option
|
||||||
|
self.name = name
|
||||||
|
self.valid_keys = valid_keys
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.dropdown = MarkupDropdown(caller=self.input, border_margin=dp(2),
|
||||||
|
width=self.input.width, position="bottom")
|
||||||
|
self.input.bind(text=self.on_text)
|
||||||
|
self.input.bind(on_text_validate=self.validate_add)
|
||||||
|
|
||||||
|
def validate_add(self, instance):
|
||||||
|
if self.valid_keys:
|
||||||
|
if self.input.text not in self.valid_keys:
|
||||||
|
MDSnackbar(MDSnackbarText(text="Item must be a valid key for this option."), y=dp(24),
|
||||||
|
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not issubclass(self.option, OptionList):
|
||||||
|
if any(self.input.text == child.text.text for child in self.scrollbox.layout.children):
|
||||||
|
MDSnackbar(MDSnackbarText(text="This value is already in the set."), y=dp(24),
|
||||||
|
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.add_set_item(self.input.text)
|
||||||
|
self.input.set_text(self.input, "")
|
||||||
|
|
||||||
|
def remove_item(self, button: MDIconButton):
|
||||||
|
list_item = button.parent
|
||||||
|
self.scrollbox.layout.remove_widget(list_item)
|
||||||
|
|
||||||
|
def add_set_item(self, key: str, value: int | None = None):
|
||||||
|
text = MDListItemSupportingText(text=key, id="value")
|
||||||
|
if issubclass(self.option, OptionCounter):
|
||||||
|
value_txt = CounterItemValue(text=str(value) if value else "1")
|
||||||
|
item = MDListItem(text,
|
||||||
|
value_txt,
|
||||||
|
MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
|
||||||
|
item.value = value_txt
|
||||||
|
else:
|
||||||
|
item = MDListItem(text, MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
|
||||||
|
item.text = text
|
||||||
|
self.scrollbox.layout.add_widget(item)
|
||||||
|
|
||||||
|
def on_text(self, instance, value):
|
||||||
|
if not self.valid_keys:
|
||||||
|
return
|
||||||
|
if len(value) >= 3:
|
||||||
|
self.dropdown.items.clear()
|
||||||
|
|
||||||
|
def on_press(txt):
|
||||||
|
split_text = MarkupLabel(text=txt, markup=True).markup
|
||||||
|
self.input.set_text(self.input, "".join(text_frag for text_frag in split_text
|
||||||
|
if not text_frag.startswith("[")))
|
||||||
|
self.input.focus = True
|
||||||
|
self.dropdown.dismiss()
|
||||||
|
|
||||||
|
lowered = value.lower()
|
||||||
|
for item_name in self.valid_keys:
|
||||||
|
try:
|
||||||
|
index = item_name.lower().index(lowered)
|
||||||
|
except ValueError:
|
||||||
|
pass # substring not found
|
||||||
|
else:
|
||||||
|
text = escape_markup(item_name)
|
||||||
|
text = text[:index] + "[b]" + text[index:index + len(value)] + "[/b]" + text[index + len(value):]
|
||||||
|
self.dropdown.items.append({
|
||||||
|
"text": text,
|
||||||
|
"on_release": lambda txt=text: on_press(txt),
|
||||||
|
"markup": True
|
||||||
|
})
|
||||||
|
if not self.dropdown.parent:
|
||||||
|
self.dropdown.open()
|
||||||
|
else:
|
||||||
|
self.dropdown.dismiss()
|
||||||
|
|
||||||
|
|
||||||
|
class OptionsCreator(ThemedApp):
|
||||||
|
base_title: str = "Archipelago Options Creator"
|
||||||
|
container: ContainerLayout
|
||||||
|
main_layout: MainLayout
|
||||||
|
scrollbox: ScrollBox
|
||||||
|
main_panel: MainLayout
|
||||||
|
player_options: MainLayout
|
||||||
|
option_layout: MainLayout
|
||||||
|
name_input: ResizableTextField
|
||||||
|
game_label: MDLabel
|
||||||
|
current_game: str
|
||||||
|
options: typing.Dict[str, typing.Any]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.title = self.base_title + " " + Utils.__version__
|
||||||
|
self.icon = r"data/icon.png"
|
||||||
|
self.current_game = ""
|
||||||
|
self.options = {}
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def export_options(self, button: Widget):
|
||||||
|
if 0 < len(self.name_input.text) < 17 and self.current_game:
|
||||||
|
file_name = Utils.save_filename("Export Options File As...", [("YAML", ["*.yaml"])],
|
||||||
|
Utils.get_file_safe_name(f"{self.name_input.text}.yaml"))
|
||||||
|
options = {
|
||||||
|
"name": self.name_input.text,
|
||||||
|
"description": f"YAML generated by Archipelago {Utils.__version__}.",
|
||||||
|
"game": self.current_game,
|
||||||
|
self.current_game: {k: check_random(v) for k, v in self.options.items()}
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(file_name, 'w') as f:
|
||||||
|
f.write(Utils.dump(options, sort_keys=False))
|
||||||
|
f.close()
|
||||||
|
MDSnackbar(MDSnackbarText(text="File saved successfully."), y=dp(24), pos_hint={"center_x": 0.5},
|
||||||
|
size_hint_x=0.5).open()
|
||||||
|
except FileNotFoundError:
|
||||||
|
MDSnackbar(MDSnackbarText(text="Saving cancelled."), y=dp(24), pos_hint={"center_x": 0.5},
|
||||||
|
size_hint_x=0.5).open()
|
||||||
|
elif not self.name_input.text:
|
||||||
|
MDSnackbar(MDSnackbarText(text="Name must not be empty."), y=dp(24), pos_hint={"center_x": 0.5},
|
||||||
|
size_hint_x=0.5).open()
|
||||||
|
elif not self.current_game:
|
||||||
|
MDSnackbar(MDSnackbarText(text="You must select a game to play."), y=dp(24), pos_hint={"center_x": 0.5},
|
||||||
|
size_hint_x=0.5).open()
|
||||||
|
else:
|
||||||
|
MDSnackbar(MDSnackbarText(text="Name cannot be longer than 16 characters."), y=dp(24),
|
||||||
|
pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
|
||||||
|
|
||||||
|
def create_range(self, option: typing.Type[Range], name: str):
|
||||||
|
def update_text(range_box: VisualRange):
|
||||||
|
self.options[name] = int(range_box.slider.value)
|
||||||
|
range_box.tag.text = str(int(range_box.slider.value))
|
||||||
|
return
|
||||||
|
|
||||||
|
box = VisualRange(option=option, name=name)
|
||||||
|
box.slider.bind(on_touch_move=lambda _, _1: update_text(box))
|
||||||
|
self.options[name] = option.default
|
||||||
|
return box
|
||||||
|
|
||||||
|
def create_named_range(self, option: typing.Type[NamedRange], name: str):
|
||||||
|
def set_to_custom(range_box: VisualNamedRange):
|
||||||
|
if (not self.options[name] == range_box.range.slider.value) \
|
||||||
|
and (not self.options[name] in option.special_range_names or
|
||||||
|
range_box.range.slider.value != option.special_range_names[self.options[name]]):
|
||||||
|
# we should validate the touch here,
|
||||||
|
# but this is much cheaper
|
||||||
|
self.options[name] = int(range_box.range.slider.value)
|
||||||
|
range_box.range.tag.text = str(int(range_box.range.slider.value))
|
||||||
|
set_button_text(range_box.choice, "Custom")
|
||||||
|
|
||||||
|
def set_button_text(button: MDButton, text: str):
|
||||||
|
button.text.text = text
|
||||||
|
|
||||||
|
def set_value(text: str, range_box: VisualNamedRange):
|
||||||
|
range_box.range.slider.value = min(max(option.special_range_names[text.lower()], option.range_start),
|
||||||
|
option.range_end)
|
||||||
|
range_box.range.tag.text = str(int(range_box.range.slider.value))
|
||||||
|
set_button_text(range_box.choice, text)
|
||||||
|
self.options[name] = text.lower()
|
||||||
|
range_box.range.slider.dropdown.dismiss()
|
||||||
|
|
||||||
|
def open_dropdown(button):
|
||||||
|
# for some reason this fixes an issue causing some to not open
|
||||||
|
box.range.slider.dropdown.open()
|
||||||
|
|
||||||
|
box = VisualNamedRange(option=option, name=name, range_widget=self.create_range(option, name))
|
||||||
|
if option.default in option.special_range_names:
|
||||||
|
# value can get mismatched in this case
|
||||||
|
box.range.slider.value = min(max(option.special_range_names[option.default], option.range_start),
|
||||||
|
option.range_end)
|
||||||
|
box.range.tag.text = str(int(box.range.slider.value))
|
||||||
|
box.range.slider.bind(on_touch_move=lambda _, _2: set_to_custom(box))
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"text": choice.title(),
|
||||||
|
"on_release": lambda text=choice.title(): set_value(text, box)
|
||||||
|
}
|
||||||
|
for choice in option.special_range_names
|
||||||
|
]
|
||||||
|
box.range.slider.dropdown = MDDropdownMenu(caller=box.choice, items=items)
|
||||||
|
box.choice.bind(on_release=open_dropdown)
|
||||||
|
self.options[name] = option.default
|
||||||
|
return box
|
||||||
|
|
||||||
|
def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str):
|
||||||
|
text = VisualFreeText(option=option, name=name)
|
||||||
|
|
||||||
|
def set_value(instance):
|
||||||
|
self.options[name] = instance.text
|
||||||
|
|
||||||
|
text.bind(on_text_validate=set_value)
|
||||||
|
return text
|
||||||
|
|
||||||
|
def create_choice(self, option: typing.Type[Choice], name: str):
|
||||||
|
def set_button_text(button: VisualChoice, text: str):
|
||||||
|
button.text.text = text
|
||||||
|
|
||||||
|
def set_value(text, value):
|
||||||
|
set_button_text(main_button, text)
|
||||||
|
self.options[name] = value
|
||||||
|
dropdown.dismiss()
|
||||||
|
|
||||||
|
def open_dropdown(button):
|
||||||
|
# for some reason this fixes an issue causing some to not open
|
||||||
|
dropdown.open()
|
||||||
|
|
||||||
|
default_string = isinstance(option.default, str)
|
||||||
|
main_button = VisualChoice(option=option, name=name)
|
||||||
|
main_button.bind(on_release=open_dropdown)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"text": option.get_option_name(choice),
|
||||||
|
"on_release": lambda val=choice: set_value(option.get_option_name(val), option.name_lookup[val])
|
||||||
|
}
|
||||||
|
for choice in option.name_lookup
|
||||||
|
]
|
||||||
|
dropdown = MDDropdownMenu(caller=main_button, items=items)
|
||||||
|
self.options[name] = option.name_lookup[option.default] if not default_string else option.default
|
||||||
|
return main_button
|
||||||
|
|
||||||
|
def create_text_choice(self, option: typing.Type[TextChoice], name: str):
|
||||||
|
def set_button_text(button: MDButton, text: str):
|
||||||
|
for child in button.children:
|
||||||
|
if isinstance(child, MDButtonText):
|
||||||
|
child.text = text
|
||||||
|
|
||||||
|
box = VisualTextChoice(option=option, name=name, choice=self.create_choice(option, name),
|
||||||
|
text=self.create_free_text(option, name))
|
||||||
|
|
||||||
|
def set_value(instance):
|
||||||
|
set_button_text(box.choice, "Custom")
|
||||||
|
self.options[name] = instance.text
|
||||||
|
|
||||||
|
box.text.bind(on_text_validate=set_value)
|
||||||
|
return box
|
||||||
|
|
||||||
|
def create_toggle(self, option: typing.Type[Toggle], name: str) -> Widget:
|
||||||
|
def set_value(instance: MDIconButton):
|
||||||
|
if instance.icon == "checkbox-outline":
|
||||||
|
instance.icon = "checkbox-blank-outline"
|
||||||
|
else:
|
||||||
|
instance.icon = "checkbox-outline"
|
||||||
|
self.options[name] = bool(not self.options[name])
|
||||||
|
|
||||||
|
self.options[name] = bool(option.default)
|
||||||
|
checkbox = VisualToggle(option=option, name=name)
|
||||||
|
checkbox.button.bind(on_release=set_value)
|
||||||
|
|
||||||
|
return checkbox
|
||||||
|
|
||||||
|
def create_popup(self, option: typing.Type[OptionList] | typing.Type[OptionSet] | typing.Type[OptionCounter],
|
||||||
|
name: str, world: typing.Type[World]):
|
||||||
|
|
||||||
|
valid_keys = sorted(option.valid_keys)
|
||||||
|
if option.verify_item_name:
|
||||||
|
valid_keys += list(world.item_name_to_id.keys())
|
||||||
|
if option.verify_location_name:
|
||||||
|
valid_keys += list(world.location_name_to_id.keys())
|
||||||
|
|
||||||
|
if not issubclass(option, OptionCounter):
|
||||||
|
def apply_changes(button):
|
||||||
|
self.options[name].clear()
|
||||||
|
for list_item in dialog.scrollbox.layout.children:
|
||||||
|
self.options[name].append(getattr(list_item.text, "text"))
|
||||||
|
dialog.dismiss()
|
||||||
|
else:
|
||||||
|
def apply_changes(button):
|
||||||
|
self.options[name].clear()
|
||||||
|
for list_item in dialog.scrollbox.layout.children:
|
||||||
|
self.options[name][getattr(list_item.text, "text")] = int(getattr(list_item.value, "text"))
|
||||||
|
dialog.dismiss()
|
||||||
|
|
||||||
|
dialog = VisualListSetCounter(option=option, name=name, valid_keys=valid_keys)
|
||||||
|
dialog.ids.container.spacing = dp(30)
|
||||||
|
dialog.scrollbox.layout.theme_bg_color = "Custom"
|
||||||
|
dialog.scrollbox.layout.md_bg_color = self.theme_cls.surfaceContainerLowColor
|
||||||
|
dialog.scrollbox.layout.spacing = dp(5)
|
||||||
|
dialog.scrollbox.layout.padding = [0, dp(5), 0, 0]
|
||||||
|
|
||||||
|
if name not in self.options:
|
||||||
|
# convert from non-mutable to mutable
|
||||||
|
# We use list syntax even for sets, set behavior is enforced through GUI
|
||||||
|
if issubclass(option, OptionCounter):
|
||||||
|
self.options[name] = deepcopy(option.default)
|
||||||
|
else:
|
||||||
|
self.options[name] = sorted(option.default)
|
||||||
|
|
||||||
|
if issubclass(option, OptionCounter):
|
||||||
|
for value in sorted(self.options[name]):
|
||||||
|
dialog.add_set_item(value, self.options[name].get(value, None))
|
||||||
|
else:
|
||||||
|
for value in sorted(self.options[name]):
|
||||||
|
dialog.add_set_item(value)
|
||||||
|
|
||||||
|
dialog.save.bind(on_release=apply_changes)
|
||||||
|
dialog.open()
|
||||||
|
|
||||||
|
def create_option_set_list_counter(self, option: typing.Type[OptionList] | typing.Type[OptionSet] |
|
||||||
|
typing.Type[OptionCounter], name: str, world: typing.Type[World]):
|
||||||
|
main_button = MDButton(MDButtonText(text="Edit"), on_release=lambda x: self.create_popup(option, name, world))
|
||||||
|
return main_button
|
||||||
|
|
||||||
|
def create_option(self, option: typing.Type[Option], name: str, world: typing.Type[World]) -> Widget:
|
||||||
|
option_base = MDBoxLayout(orientation="vertical", size_hint_y=None, padding=[0, 0, dp(5), dp(5)])
|
||||||
|
|
||||||
|
tooltip = filter_tooltip(option.__doc__)
|
||||||
|
option_label = TooltipLabel(text=f"[ref=0|{tooltip}]{getattr(option, 'display_name', name)}")
|
||||||
|
label_box = MDBoxLayout(orientation="horizontal")
|
||||||
|
label_anchor = MDAnchorLayout(anchor_x="right", anchor_y="center")
|
||||||
|
label_anchor.add_widget(option_label)
|
||||||
|
label_box.add_widget(label_anchor)
|
||||||
|
|
||||||
|
option_base.add_widget(label_box)
|
||||||
|
if issubclass(option, NamedRange):
|
||||||
|
option_base.add_widget(self.create_named_range(option, name))
|
||||||
|
elif issubclass(option, Range):
|
||||||
|
option_base.add_widget(self.create_range(option, name))
|
||||||
|
elif issubclass(option, Toggle):
|
||||||
|
option_base.add_widget(self.create_toggle(option, name))
|
||||||
|
elif issubclass(option, TextChoice):
|
||||||
|
option_base.add_widget(self.create_text_choice(option, name))
|
||||||
|
elif issubclass(option, Choice):
|
||||||
|
option_base.add_widget(self.create_choice(option, name))
|
||||||
|
elif issubclass(option, FreeText):
|
||||||
|
option_base.add_widget(self.create_free_text(option, name))
|
||||||
|
elif any(issubclass(option, cls) for cls in (OptionSet, OptionList, OptionCounter)):
|
||||||
|
option_base.add_widget(self.create_option_set_list_counter(option, name, world))
|
||||||
|
else:
|
||||||
|
option_base.add_widget(MDLabel(text="This option isn't supported by the option creator.\n"
|
||||||
|
"Please edit your yaml manually to set this option."))
|
||||||
|
|
||||||
|
if option_can_be_randomized(option):
|
||||||
|
def randomize_option(instance: Widget, value: str):
|
||||||
|
value = value == "down"
|
||||||
|
if value:
|
||||||
|
self.options[name] = "random-" + str(self.options[name])
|
||||||
|
else:
|
||||||
|
self.options[name] = self.options[name].replace("random-", "")
|
||||||
|
if self.options[name].isnumeric() or self.options[name] in ("True", "False"):
|
||||||
|
self.options[name] = eval(self.options[name])
|
||||||
|
|
||||||
|
base_object = instance.parent.parent
|
||||||
|
label_object = instance.parent
|
||||||
|
for child in base_object.children:
|
||||||
|
if child is not label_object:
|
||||||
|
child.disabled = value
|
||||||
|
|
||||||
|
default_random = option.default == "random"
|
||||||
|
random_toggle = ToggleButton(MDButtonText(text="Random?"), size_hint_x=None, width=dp(100),
|
||||||
|
state="down" if default_random else "normal")
|
||||||
|
random_toggle.bind(state=randomize_option)
|
||||||
|
label_box.add_widget(random_toggle)
|
||||||
|
if default_random:
|
||||||
|
randomize_option(random_toggle, "down")
|
||||||
|
|
||||||
|
return option_base
|
||||||
|
|
||||||
|
def create_options_panel(self, world_button: WorldButton):
|
||||||
|
self.option_layout.clear_widgets()
|
||||||
|
self.options.clear()
|
||||||
|
cls: typing.Type[World] = world_button.world_cls
|
||||||
|
|
||||||
|
self.current_game = cls.game
|
||||||
|
if not cls.web.options_page:
|
||||||
|
self.current_game = "None"
|
||||||
|
return
|
||||||
|
elif isinstance(cls.web.options_page, str):
|
||||||
|
self.current_game = "None"
|
||||||
|
if validate_url(cls.web.options_page):
|
||||||
|
webbrowser.open(cls.web.options_page)
|
||||||
|
MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24), pos_hint={"center_x": 0.5},
|
||||||
|
size_hint_x=0.5).open()
|
||||||
|
world_button.state = "normal"
|
||||||
|
else:
|
||||||
|
# attach onto archipelago.gg and see if we pass
|
||||||
|
new_url = "https://archipelago.gg/" + cls.web.options_page
|
||||||
|
if validate_url(new_url):
|
||||||
|
webbrowser.open(new_url)
|
||||||
|
MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24),
|
||||||
|
pos_hint={"center_x": 0.5},
|
||||||
|
size_hint_x=0.5).open()
|
||||||
|
else:
|
||||||
|
MDSnackbar(MDSnackbarText(text="Invalid options page, please report to world developer."), y=dp(24),
|
||||||
|
pos_hint={"center_x": 0.5},
|
||||||
|
size_hint_x=0.5).open()
|
||||||
|
world_button.state = "normal"
|
||||||
|
# else just fall through
|
||||||
|
else:
|
||||||
|
expansion_box = ScrollBox()
|
||||||
|
expansion_box.layout.orientation = "vertical"
|
||||||
|
expansion_box.layout.spacing = dp(3)
|
||||||
|
expansion_box.scroll_type = ["bars"]
|
||||||
|
expansion_box.do_scroll_x = False
|
||||||
|
group_names = ["Game Options", *(group.name for group in cls.web.option_groups)]
|
||||||
|
groups = {name: [] for name in group_names}
|
||||||
|
for name, option in cls.options_dataclass.type_hints.items():
|
||||||
|
group = next((group.name for group in cls.web.option_groups if option in group.options), "Game Options")
|
||||||
|
groups[group].append((name, option))
|
||||||
|
|
||||||
|
for group, options in groups.items():
|
||||||
|
options = [(name, option) for name, option in options
|
||||||
|
if name and option.visibility & Visibility.simple_ui]
|
||||||
|
if not options:
|
||||||
|
continue # Game Options can be empty if every other option is in another group
|
||||||
|
# Can also have an option group of options that should not render on simple ui
|
||||||
|
group_item = MDExpansionPanel(size_hint_y=None)
|
||||||
|
group_header = MDExpansionPanelHeader(MDListItem(MDListItemSupportingText(text=group),
|
||||||
|
TrailingPressedIconButton(icon="chevron-right",
|
||||||
|
on_release=lambda x,
|
||||||
|
item=group_item:
|
||||||
|
self.tap_expansion_chevron(
|
||||||
|
item, x)),
|
||||||
|
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
|
||||||
|
theme_bg_color="Custom",
|
||||||
|
on_release=lambda x, item=group_item:
|
||||||
|
self.tap_expansion_chevron(item, x)))
|
||||||
|
group_content = MDExpansionPanelContent(orientation="vertical", theme_bg_color="Custom",
|
||||||
|
md_bg_color=self.theme_cls.surfaceContainerLowestColor,
|
||||||
|
padding=[dp(12), dp(100), dp(12), 0],
|
||||||
|
spacing=dp(3))
|
||||||
|
group_item.add_widget(group_header)
|
||||||
|
group_item.add_widget(group_content)
|
||||||
|
group_box = ScrollBox()
|
||||||
|
group_box.layout.orientation = "vertical"
|
||||||
|
group_box.layout.spacing = dp(3)
|
||||||
|
for name, option in options:
|
||||||
|
group_content.add_widget(self.create_option(option, name, cls))
|
||||||
|
expansion_box.layout.add_widget(group_item)
|
||||||
|
self.option_layout.add_widget(expansion_box)
|
||||||
|
self.game_label.text = f"Game: {self.current_game}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def tap_expansion_chevron(panel: MDExpansionPanel, chevron: TrailingPressedIconButton | MDListItem):
|
||||||
|
if isinstance(chevron, MDListItem):
|
||||||
|
chevron = next((child for child in chevron.ids.trailing_container.children
|
||||||
|
if isinstance(child, TrailingPressedIconButton)), None)
|
||||||
|
panel.open() if not panel.is_open else panel.close()
|
||||||
|
if chevron:
|
||||||
|
panel.set_chevron_down(
|
||||||
|
chevron
|
||||||
|
) if not panel.is_open else panel.set_chevron_up(chevron)
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
self.set_colors()
|
||||||
|
self.options = {}
|
||||||
|
self.container = Builder.load_file(Utils.local_path("data/optionscreator.kv"))
|
||||||
|
self.root = self.container
|
||||||
|
self.main_layout = self.container.ids.main
|
||||||
|
self.scrollbox = self.container.ids.scrollbox
|
||||||
|
|
||||||
|
def world_button_action(world_btn: WorldButton):
|
||||||
|
if self.current_game != world_btn.world_cls.game:
|
||||||
|
old_button = next((button for button in self.scrollbox.layout.children
|
||||||
|
if button.world_cls.game == self.current_game), None)
|
||||||
|
if old_button:
|
||||||
|
old_button.state = "normal"
|
||||||
|
else:
|
||||||
|
world_btn.state = "down"
|
||||||
|
self.create_options_panel(world_btn)
|
||||||
|
|
||||||
|
for world, cls in sorted(AutoWorldRegister.world_types.items(), key=lambda x: x[0]):
|
||||||
|
if world == "Archipelago":
|
||||||
|
continue
|
||||||
|
world_text = MDButtonText(text=world, size_hint_y=None, width=dp(150),
|
||||||
|
pos_hint={"x": 0.03, "center_y": 0.5})
|
||||||
|
world_text.text_size = (world_text.width, None)
|
||||||
|
world_text.bind(width=lambda *x, text=world_text: text.setter('text_size')(text, (text.width, None)),
|
||||||
|
texture_size=lambda *x, text=world_text: text.setter("height")(text,
|
||||||
|
world_text.texture_size[1]))
|
||||||
|
world_button = WorldButton(world_text, size_hint_x=None, width=dp(150), theme_width="Custom",
|
||||||
|
radius=(dp(5), dp(5), dp(5), dp(5)))
|
||||||
|
world_button.bind(on_release=world_button_action)
|
||||||
|
world_button.world_cls = cls
|
||||||
|
self.scrollbox.layout.add_widget(world_button)
|
||||||
|
self.main_panel = self.container.ids.player_layout
|
||||||
|
self.player_options = self.container.ids.player_options
|
||||||
|
self.game_label = self.container.ids.game
|
||||||
|
self.name_input = self.container.ids.player_name
|
||||||
|
self.option_layout = self.container.ids.options
|
||||||
|
|
||||||
|
def set_height(instance, value):
|
||||||
|
instance.height = value[1]
|
||||||
|
|
||||||
|
self.game_label.bind(texture_size=set_height)
|
||||||
|
|
||||||
|
# Uncomment to re-enable the Kivy console/live editor
|
||||||
|
# Ctrl-E to enable it, make sure numlock/capslock is disabled
|
||||||
|
# from kivy.modules.console import create_console
|
||||||
|
# from kivy.core.window import Window
|
||||||
|
# create_console(Window, self.container)
|
||||||
|
|
||||||
|
return self.container
|
||||||
|
|
||||||
|
|
||||||
|
def launch():
|
||||||
|
OptionsCreator().run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
Utils.init_logging("OptionsCreator")
|
||||||
|
launch()
|
||||||
19
README.md
19
README.md
@@ -7,23 +7,19 @@ Currently, the following games are supported:
|
|||||||
|
|
||||||
* The Legend of Zelda: A Link to the Past
|
* The Legend of Zelda: A Link to the Past
|
||||||
* Factorio
|
* Factorio
|
||||||
* Minecraft
|
|
||||||
* Subnautica
|
* Subnautica
|
||||||
* Slay the Spire
|
|
||||||
* Risk of Rain 2
|
* Risk of Rain 2
|
||||||
* The Legend of Zelda: Ocarina of Time
|
* The Legend of Zelda: Ocarina of Time
|
||||||
* Timespinner
|
* Timespinner
|
||||||
* Super Metroid
|
* Super Metroid
|
||||||
* Secret of Evermore
|
* Secret of Evermore
|
||||||
* Final Fantasy
|
* Final Fantasy
|
||||||
* Rogue Legacy
|
|
||||||
* VVVVVV
|
* VVVVVV
|
||||||
* Raft
|
* Raft
|
||||||
* Super Mario 64
|
* Super Mario 64
|
||||||
* Meritous
|
* Meritous
|
||||||
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
||||||
* ChecksFinder
|
* ChecksFinder
|
||||||
* ArchipIDLE
|
|
||||||
* Hollow Knight
|
* Hollow Knight
|
||||||
* The Witness
|
* The Witness
|
||||||
* Sonic Adventure 2: Battle
|
* Sonic Adventure 2: Battle
|
||||||
@@ -43,7 +39,6 @@ Currently, the following games are supported:
|
|||||||
* The Messenger
|
* The Messenger
|
||||||
* Kingdom Hearts 2
|
* Kingdom Hearts 2
|
||||||
* The Legend of Zelda: Link's Awakening DX
|
* The Legend of Zelda: Link's Awakening DX
|
||||||
* Clique
|
|
||||||
* Adventure
|
* Adventure
|
||||||
* DLC Quest
|
* DLC Quest
|
||||||
* Noita
|
* Noita
|
||||||
@@ -63,7 +58,6 @@ Currently, the following games are supported:
|
|||||||
* TUNIC
|
* TUNIC
|
||||||
* Kirby's Dream Land 3
|
* Kirby's Dream Land 3
|
||||||
* Celeste 64
|
* Celeste 64
|
||||||
* Zork Grand Inquisitor
|
|
||||||
* Castlevania 64
|
* Castlevania 64
|
||||||
* A Short Hike
|
* A Short Hike
|
||||||
* Yoshi's Island
|
* Yoshi's Island
|
||||||
@@ -76,6 +70,19 @@ Currently, the following games are supported:
|
|||||||
* Kingdom Hearts 1
|
* Kingdom Hearts 1
|
||||||
* Mega Man 2
|
* Mega Man 2
|
||||||
* Yacht Dice
|
* Yacht Dice
|
||||||
|
* Faxanadu
|
||||||
|
* Saving Princess
|
||||||
|
* Castlevania: Circle of the Moon
|
||||||
|
* Inscryption
|
||||||
|
* Civilization VI
|
||||||
|
* The Legend of Zelda: The Wind Waker
|
||||||
|
* Jak and Daxter: The Precursor Legacy
|
||||||
|
* Super Mario Land 2: 6 Golden Coins
|
||||||
|
* shapez
|
||||||
|
* Paint
|
||||||
|
* Celeste (Open World)
|
||||||
|
* Choo-Choo Charles
|
||||||
|
* APQuest
|
||||||
|
|
||||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||||
|
|||||||
11
SNIClient.py
11
SNIClient.py
@@ -18,6 +18,7 @@ from json import loads, dumps
|
|||||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
import settings
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from MultiServer import mark_raw
|
from MultiServer import mark_raw
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
@@ -243,6 +244,9 @@ class SNIContext(CommonContext):
|
|||||||
# Once the games handled by SNIClient gets made to be remote items,
|
# Once the games handled by SNIClient gets made to be remote items,
|
||||||
# this will no longer be needed.
|
# this will no longer be needed.
|
||||||
async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
|
async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
|
||||||
|
|
||||||
|
if self.client_handler is not None:
|
||||||
|
self.client_handler.on_package(self, cmd, args)
|
||||||
|
|
||||||
def run_gui(self) -> None:
|
def run_gui(self) -> None:
|
||||||
from kvui import GameManager
|
from kvui import GameManager
|
||||||
@@ -282,7 +286,7 @@ class SNESState(enum.IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
def launch_sni() -> None:
|
def launch_sni() -> None:
|
||||||
sni_path = Utils.get_settings()["sni_options"]["sni_path"]
|
sni_path = settings.get_settings().sni_options.sni_path
|
||||||
|
|
||||||
if not os.path.isdir(sni_path):
|
if not os.path.isdir(sni_path):
|
||||||
sni_path = Utils.local_path(sni_path)
|
sni_path = Utils.local_path(sni_path)
|
||||||
@@ -665,8 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile: str) -> None:
|
async def run_game(romfile: str) -> None:
|
||||||
auto_start = typing.cast(typing.Union[bool, str],
|
auto_start = settings.get_settings().sni_options.snes_rom_start
|
||||||
Utils.get_settings()["sni_options"].get("snes_rom_start", True))
|
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
@@ -732,6 +735,6 @@ async def main() -> None:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
from worlds.sc2.Client import launch
|
|
||||||
import Utils
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
Utils.init_logging("Starcraft2Client", exception_logger="Client")
|
|
||||||
launch()
|
|
||||||
@@ -500,7 +500,7 @@ def main():
|
|||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
colorama.init()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
asyncio.run(_main())
|
asyncio.run(_main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
|
|||||||
294
Utils.py
294
Utils.py
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
import typing
|
import typing
|
||||||
import builtins
|
import builtins
|
||||||
@@ -35,7 +36,7 @@ if typing.TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
def tuplize_version(version: str) -> Version:
|
def tuplize_version(version: str) -> Version:
|
||||||
return Version(*(int(piece, 10) for piece in version.split(".")))
|
return Version(*(int(piece) for piece in version.split(".")))
|
||||||
|
|
||||||
|
|
||||||
class Version(typing.NamedTuple):
|
class Version(typing.NamedTuple):
|
||||||
@@ -47,7 +48,7 @@ class Version(typing.NamedTuple):
|
|||||||
return ".".join(str(item) for item in self)
|
return ".".join(str(item) for item in self)
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.6.0"
|
__version__ = "0.6.5"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
@@ -114,6 +115,8 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
|
|||||||
cache[arg] = res
|
cache[arg] = res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
wrap.__defaults__ = function.__defaults__
|
||||||
|
|
||||||
return wrap
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
@@ -137,8 +140,11 @@ def local_path(*path: str) -> str:
|
|||||||
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
||||||
else:
|
else:
|
||||||
import __main__
|
import __main__
|
||||||
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
if globals().get("__file__") and os.path.isfile(__file__):
|
||||||
# we are running in a normal Python environment
|
# we are running in a normal Python environment
|
||||||
|
local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
||||||
|
# we are running in a normal Python environment, but AP was imported weirdly
|
||||||
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
||||||
else:
|
else:
|
||||||
# pray
|
# pray
|
||||||
@@ -152,7 +158,18 @@ def home_path(*path: str) -> str:
|
|||||||
if hasattr(home_path, 'cached_path'):
|
if hasattr(home_path, 'cached_path'):
|
||||||
pass
|
pass
|
||||||
elif sys.platform.startswith('linux'):
|
elif sys.platform.startswith('linux'):
|
||||||
home_path.cached_path = os.path.expanduser('~/Archipelago')
|
xdg_data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
|
||||||
|
home_path.cached_path = xdg_data_home + '/Archipelago'
|
||||||
|
if not os.path.isdir(home_path.cached_path):
|
||||||
|
legacy_home_path = os.path.expanduser('~/Archipelago')
|
||||||
|
if os.path.isdir(legacy_home_path):
|
||||||
|
os.renames(legacy_home_path, home_path.cached_path)
|
||||||
|
os.symlink(home_path.cached_path, legacy_home_path)
|
||||||
|
else:
|
||||||
|
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
||||||
|
elif sys.platform == 'darwin':
|
||||||
|
import platformdirs
|
||||||
|
home_path.cached_path = platformdirs.user_data_dir("Archipelago", False)
|
||||||
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
||||||
else:
|
else:
|
||||||
# not implemented
|
# not implemented
|
||||||
@@ -165,7 +182,7 @@ def user_path(*path: str) -> str:
|
|||||||
"""Returns either local_path or home_path based on write permissions."""
|
"""Returns either local_path or home_path based on write permissions."""
|
||||||
if hasattr(user_path, "cached_path"):
|
if hasattr(user_path, "cached_path"):
|
||||||
pass
|
pass
|
||||||
elif os.access(local_path(), os.W_OK):
|
elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()):
|
||||||
user_path.cached_path = local_path()
|
user_path.cached_path = local_path()
|
||||||
else:
|
else:
|
||||||
user_path.cached_path = home_path()
|
user_path.cached_path = home_path()
|
||||||
@@ -214,7 +231,12 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
|
|||||||
from shutil import which
|
from shutil import which
|
||||||
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
|
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
|
||||||
assert open_command, "Didn't find program for open_file! Please report this together with system details."
|
assert open_command, "Didn't find program for open_file! Please report this together with system details."
|
||||||
subprocess.call([open_command, filename])
|
|
||||||
|
env = os.environ
|
||||||
|
if "LD_LIBRARY_PATH" in env:
|
||||||
|
env = env.copy()
|
||||||
|
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||||
|
subprocess.call([open_command, filename], env=env)
|
||||||
|
|
||||||
|
|
||||||
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
|
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
|
||||||
@@ -292,20 +314,18 @@ def get_public_ipv6() -> str:
|
|||||||
return ip
|
return ip
|
||||||
|
|
||||||
|
|
||||||
OptionsType = Settings # TODO: remove when removing get_options
|
|
||||||
|
|
||||||
|
|
||||||
def get_options() -> Settings:
|
def get_options() -> Settings:
|
||||||
# TODO: switch to Utils.deprecate after 0.4.4
|
deprecate("Utils.get_options() is deprecated. Use the settings API instead.")
|
||||||
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
|
|
||||||
return get_settings()
|
return get_settings()
|
||||||
|
|
||||||
|
|
||||||
def persistent_store(category: str, key: str, value: typing.Any):
|
def persistent_store(category: str, key: str, value: typing.Any, force_store: bool = False):
|
||||||
path = user_path("_persistent_storage.yaml")
|
|
||||||
storage = persistent_load()
|
storage = persistent_load()
|
||||||
|
if not force_store and category in storage and key in storage[category] and storage[category][key] == value:
|
||||||
|
return # no changes necessary
|
||||||
category_dict = storage.setdefault(category, {})
|
category_dict = storage.setdefault(category, {})
|
||||||
category_dict[key] = value
|
category_dict[key] = value
|
||||||
|
path = user_path("_persistent_storage.yaml")
|
||||||
with open(path, "wt") as f:
|
with open(path, "wt") as f:
|
||||||
f.write(dump(storage, Dumper=Dumper))
|
f.write(dump(storage, Dumper=Dumper))
|
||||||
|
|
||||||
@@ -392,13 +412,26 @@ def get_adjuster_settings(game_name: str) -> Namespace:
|
|||||||
|
|
||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_unique_identifier():
|
def get_unique_identifier():
|
||||||
uuid = persistent_load().get("client", {}).get("uuid", None)
|
common_path = cache_path("common.json")
|
||||||
|
try:
|
||||||
|
with open(common_path) as f:
|
||||||
|
common_file = json.load(f)
|
||||||
|
uuid = common_file.get("uuid", None)
|
||||||
|
except FileNotFoundError:
|
||||||
|
common_file = {}
|
||||||
|
uuid = None
|
||||||
|
|
||||||
if uuid:
|
if uuid:
|
||||||
return uuid
|
return uuid
|
||||||
|
|
||||||
import uuid
|
from uuid import uuid4
|
||||||
uuid = uuid.getnode()
|
uuid = str(uuid4())
|
||||||
persistent_store("client", "uuid", uuid)
|
common_file["uuid"] = uuid
|
||||||
|
|
||||||
|
cache_folder = os.path.dirname(common_path)
|
||||||
|
os.makedirs(cache_folder, exist_ok=True)
|
||||||
|
with open(common_path, "w") as f:
|
||||||
|
json.dump(common_file, f, separators=(",", ":"))
|
||||||
return uuid
|
return uuid
|
||||||
|
|
||||||
|
|
||||||
@@ -420,8 +453,13 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
def find_class(self, module: str, name: str) -> type:
|
def find_class(self, module: str, name: str) -> type:
|
||||||
if module == "builtins" and name in safe_builtins:
|
if module == "builtins" and name in safe_builtins:
|
||||||
return getattr(builtins, name)
|
return getattr(builtins, name)
|
||||||
|
# used by OptionCounter
|
||||||
|
# necessary because the actual Options class instances are pickled when transfered to WebHost generation pool
|
||||||
|
if module == "collections" and name == "Counter":
|
||||||
|
return collections.Counter
|
||||||
# used by MultiServer -> savegame/multidata
|
# used by MultiServer -> savegame/multidata
|
||||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
|
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
|
||||||
|
"SlotType", "NetworkSlot", "HintStatus"}:
|
||||||
return getattr(self.net_utils_module, name)
|
return getattr(self.net_utils_module, name)
|
||||||
# Options and Plando are unpickled by WebHost -> Generate
|
# Options and Plando are unpickled by WebHost -> Generate
|
||||||
if module == "worlds.generic" and name == "PlandoItem":
|
if module == "worlds.generic" and name == "PlandoItem":
|
||||||
@@ -435,7 +473,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
else:
|
else:
|
||||||
mod = importlib.import_module(module)
|
mod = importlib.import_module(module)
|
||||||
obj = getattr(mod, name)
|
obj = getattr(mod, name)
|
||||||
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection)):
|
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
|
||||||
|
self.options_module.PlandoItem, self.options_module.PlandoText)):
|
||||||
return obj
|
return obj
|
||||||
# Forbid everything else.
|
# Forbid everything else.
|
||||||
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||||
@@ -446,6 +485,18 @@ def restricted_loads(s: bytes) -> Any:
|
|||||||
return RestrictedUnpickler(io.BytesIO(s)).load()
|
return RestrictedUnpickler(io.BytesIO(s)).load()
|
||||||
|
|
||||||
|
|
||||||
|
def restricted_dumps(obj: Any) -> bytes:
|
||||||
|
"""Helper function analogous to pickle.dumps()."""
|
||||||
|
s = pickle.dumps(obj)
|
||||||
|
# Assert that the string can be successfully loaded by restricted_loads
|
||||||
|
try:
|
||||||
|
restricted_loads(s)
|
||||||
|
except pickle.UnpicklingError as e:
|
||||||
|
raise pickle.PicklingError(e) from e
|
||||||
|
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
class ByValue:
|
class ByValue:
|
||||||
"""
|
"""
|
||||||
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
|
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
|
||||||
@@ -484,9 +535,9 @@ def get_text_after(text: str, start: str) -> str:
|
|||||||
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
|
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",
|
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
|
||||||
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
write_mode: str = "w", log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
||||||
exception_logger: typing.Optional[str] = None):
|
add_timestamp: bool = False, exception_logger: typing.Optional[str] = None):
|
||||||
import datetime
|
import datetime
|
||||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||||
log_folder = user_path("logs")
|
log_folder = user_path("logs")
|
||||||
@@ -513,12 +564,18 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
|||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
return self.condition(record)
|
return self.condition(record)
|
||||||
|
|
||||||
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
|
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
|
||||||
|
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.getMessage()))
|
||||||
root_logger.addHandler(file_handler)
|
root_logger.addHandler(file_handler)
|
||||||
if sys.stdout:
|
if sys.stdout:
|
||||||
|
formatter = logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
||||||
stream_handler = logging.StreamHandler(sys.stdout)
|
stream_handler = logging.StreamHandler(sys.stdout)
|
||||||
stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
|
stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
|
||||||
|
if add_timestamp:
|
||||||
|
stream_handler.setFormatter(formatter)
|
||||||
root_logger.addHandler(stream_handler)
|
root_logger.addHandler(stream_handler)
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
# Relay unhandled exceptions to logger.
|
# Relay unhandled exceptions to logger.
|
||||||
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
|
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
|
||||||
@@ -529,7 +586,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
|||||||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||||
return
|
return
|
||||||
logging.getLogger(exception_logger).exception("Uncaught exception",
|
logging.getLogger(exception_logger).exception("Uncaught exception",
|
||||||
exc_info=(exc_type, exc_value, exc_traceback))
|
exc_info=(exc_type, exc_value, exc_traceback),
|
||||||
|
extra={"NoStream": exception_logger is None})
|
||||||
return orig_hook(exc_type, exc_value, exc_traceback)
|
return orig_hook(exc_type, exc_value, exc_traceback)
|
||||||
|
|
||||||
handle_exception._wrapped = True
|
handle_exception._wrapped = True
|
||||||
@@ -552,7 +610,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
|||||||
import platform
|
import platform
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Archipelago ({__version__}) logging initialized"
|
f"Archipelago ({__version__}) logging initialized"
|
||||||
f" on {platform.platform()}"
|
f" on {platform.platform()} process {os.getpid()}"
|
||||||
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||||
f"{' (frozen)' if is_frozen() else ''}"
|
f"{' (frozen)' if is_frozen() else ''}"
|
||||||
)
|
)
|
||||||
@@ -616,6 +674,8 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
|
|||||||
import jellyfish
|
import jellyfish
|
||||||
|
|
||||||
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
||||||
|
if word1 == word2:
|
||||||
|
return 1.01
|
||||||
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
||||||
/ max(len(word1), len(word2)))
|
/ max(len(word1), len(word2)))
|
||||||
|
|
||||||
@@ -636,8 +696,10 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
|
|||||||
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
|
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
|
||||||
if len(picks) > 1:
|
if len(picks) > 1:
|
||||||
dif = picks[0][1] - picks[1][1]
|
dif = picks[0][1] - picks[1][1]
|
||||||
if picks[0][1] == 100:
|
if picks[0][1] == 101:
|
||||||
return picks[0][0], True, "Perfect Match"
|
return picks[0][0], True, "Perfect Match"
|
||||||
|
elif picks[0][1] == 100:
|
||||||
|
return picks[0][0], True, "Case Insensitive Perfect Match"
|
||||||
elif picks[0][1] < 75:
|
elif picks[0][1] < 75:
|
||||||
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
||||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||||
@@ -655,13 +717,22 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
|
|||||||
|
|
||||||
|
|
||||||
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
|
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
|
||||||
|
"""
|
||||||
|
Parses the response text from `get_intended_text` to find the suggested input and autocomplete the command in
|
||||||
|
arguments with it.
|
||||||
|
|
||||||
|
:param text: The response text from `get_intended_text`.
|
||||||
|
:param command: The command to which the input text should be added. Must contain the prefix used by the command
|
||||||
|
(`!` or `/`).
|
||||||
|
:return: The command with the suggested input text appended, or None if no suggestion was found.
|
||||||
|
"""
|
||||||
if "did you mean " in text:
|
if "did you mean " in text:
|
||||||
for question in ("Didn't find something that closely matches",
|
for question in ("Didn't find something that closely matches",
|
||||||
"Too many close matches"):
|
"Too many close matches"):
|
||||||
if text.startswith(question):
|
if text.startswith(question):
|
||||||
name = get_text_between(text, "did you mean '",
|
name = get_text_between(text, "did you mean '",
|
||||||
"'? (")
|
"'? (")
|
||||||
return f"!{command} {name}"
|
return f"{command} {name}"
|
||||||
elif text.startswith("Missing: "):
|
elif text.startswith("Missing: "):
|
||||||
return text.replace("Missing: ", "!hint_location ")
|
return text.replace("Missing: ", "!hint_location ")
|
||||||
return None
|
return None
|
||||||
@@ -680,25 +751,35 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
|
|||||||
res.put(open_filename(*args))
|
res.put(open_filename(*args))
|
||||||
|
|
||||||
|
|
||||||
|
def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
|
||||||
|
if is_kivy_running():
|
||||||
|
raise RuntimeError("kivy should not be running in multiprocess")
|
||||||
|
res.put(save_filename(*args))
|
||||||
|
|
||||||
|
def _run_for_stdout(*args: str):
|
||||||
|
env = os.environ
|
||||||
|
if "LD_LIBRARY_PATH" in env:
|
||||||
|
env = env.copy()
|
||||||
|
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||||
|
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
|
||||||
|
|
||||||
|
|
||||||
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
||||||
-> typing.Optional[str]:
|
-> typing.Optional[str]:
|
||||||
logging.info(f"Opening file input dialog for {title}.")
|
logging.info(f"Opening file input dialog for {title}.")
|
||||||
|
|
||||||
def run(*args: str):
|
|
||||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
|
||||||
|
|
||||||
if is_linux:
|
if is_linux:
|
||||||
# prefer native dialog
|
# prefer native dialog
|
||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||||
return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
|
return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
|
||||||
zenity = which("zenity")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||||
selection = (f"--filename={suggest}",) if suggest else ()
|
selection = (f"--filename={suggest}",) if suggest else ()
|
||||||
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
||||||
|
|
||||||
# fall back to tk
|
# fall back to tk
|
||||||
try:
|
try:
|
||||||
@@ -725,13 +806,10 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
|
|||||||
initialfile=suggest or None)
|
initialfile=suggest or None)
|
||||||
|
|
||||||
|
|
||||||
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
|
def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
||||||
if is_kivy_running():
|
-> typing.Optional[str]:
|
||||||
raise RuntimeError("kivy should not be running in multiprocess")
|
logging.info(f"Opening file save dialog for {title}.")
|
||||||
res.put(open_directory(*args))
|
|
||||||
|
|
||||||
|
|
||||||
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
|
||||||
def run(*args: str):
|
def run(*args: str):
|
||||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
||||||
|
|
||||||
@@ -740,13 +818,58 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
|||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
return run(kdialog, f"--title={title}", "--getexistingdirectory",
|
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||||
|
return run(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
|
||||||
|
zenity = which("zenity")
|
||||||
|
if zenity:
|
||||||
|
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||||
|
selection = (f"--filename={suggest}",) if suggest else ()
|
||||||
|
return run(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
|
||||||
|
|
||||||
|
# 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 save_filename was used for "{title}".')
|
||||||
|
raise e
|
||||||
|
else:
|
||||||
|
if is_macos and is_kivy_running():
|
||||||
|
# on macOS, mixing kivy and tk does not work, so spawn a new process
|
||||||
|
# FIXME: performance of this is pretty bad, and we should (also) look into alternatives
|
||||||
|
from multiprocessing import Process, Queue
|
||||||
|
res: "Queue[typing.Optional[str]]" = Queue()
|
||||||
|
Process(target=_mp_save_filename, args=(res, title, filetypes, suggest)).start()
|
||||||
|
return res.get()
|
||||||
|
try:
|
||||||
|
root = tkinter.Tk()
|
||||||
|
except tkinter.TclError:
|
||||||
|
return None # GUI not available. None is the same as a user clicking "cancel"
|
||||||
|
root.withdraw()
|
||||||
|
return tkinter.filedialog.asksaveasfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
||||||
|
initialfile=suggest or None)
|
||||||
|
|
||||||
|
|
||||||
|
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
|
||||||
|
if is_kivy_running():
|
||||||
|
raise RuntimeError("kivy should not be running in multiprocess")
|
||||||
|
res.put(open_directory(*args))
|
||||||
|
|
||||||
|
|
||||||
|
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
||||||
|
if is_linux:
|
||||||
|
# prefer native dialog
|
||||||
|
from shutil import which
|
||||||
|
kdialog = which("kdialog")
|
||||||
|
if kdialog:
|
||||||
|
return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory",
|
||||||
os.path.abspath(suggest) if suggest else ".")
|
os.path.abspath(suggest) if suggest else ".")
|
||||||
zenity = which("zenity")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
z_filters = ("--directory",)
|
z_filters = ("--directory",)
|
||||||
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
|
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
|
||||||
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
||||||
|
|
||||||
# fall back to tk
|
# fall back to tk
|
||||||
try:
|
try:
|
||||||
@@ -773,9 +896,6 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def messagebox(title: str, text: str, error: bool = False) -> None:
|
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
|
|
||||||
|
|
||||||
if is_kivy_running():
|
if is_kivy_running():
|
||||||
from kvui import MessageBox
|
from kvui import MessageBox
|
||||||
MessageBox(title, text, error).open()
|
MessageBox(title, text, error).open()
|
||||||
@@ -786,10 +906,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
|||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
|
return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
|
||||||
zenity = which("zenity")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
|
return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
|
||||||
|
|
||||||
elif is_windows:
|
elif is_windows:
|
||||||
import ctypes
|
import ctypes
|
||||||
@@ -841,7 +961,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
|
|||||||
Use this to start a task when you don't keep a reference to it or immediately await it,
|
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"
|
to prevent early garbage collection. "fire-and-forget"
|
||||||
"""
|
"""
|
||||||
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
|
# https://docs.python.org/3.11/library/asyncio-task.html#asyncio.create_task
|
||||||
# Python docs:
|
# Python docs:
|
||||||
# ```
|
# ```
|
||||||
# Important: Save a reference to the result of [asyncio.create_task],
|
# Important: Save a reference to the result of [asyncio.create_task],
|
||||||
@@ -854,11 +974,10 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
|
|||||||
task.add_done_callback(_faf_tasks.discard)
|
task.add_done_callback(_faf_tasks.discard)
|
||||||
|
|
||||||
|
|
||||||
def deprecate(message: str):
|
def deprecate(message: str, add_stacklevels: int = 0):
|
||||||
if __debug__:
|
if __debug__:
|
||||||
raise Exception(message)
|
raise Exception(message)
|
||||||
import warnings
|
warnings.warn(message, stacklevel=2 + add_stacklevels)
|
||||||
warnings.warn(message)
|
|
||||||
|
|
||||||
|
|
||||||
class DeprecateDict(dict):
|
class DeprecateDict(dict):
|
||||||
@@ -872,23 +991,22 @@ class DeprecateDict(dict):
|
|||||||
|
|
||||||
def __getitem__(self, item: Any) -> Any:
|
def __getitem__(self, item: Any) -> Any:
|
||||||
if self.should_error:
|
if self.should_error:
|
||||||
deprecate(self.log_message)
|
deprecate(self.log_message, add_stacklevels=1)
|
||||||
elif __debug__:
|
elif __debug__:
|
||||||
import warnings
|
warnings.warn(self.log_message, stacklevel=2)
|
||||||
warnings.warn(self.log_message)
|
|
||||||
return super().__getitem__(item)
|
return super().__getitem__(item)
|
||||||
|
|
||||||
|
|
||||||
def _extend_freeze_support() -> None:
|
def _extend_freeze_support() -> None:
|
||||||
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
|
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first."""
|
||||||
# upstream issue: https://github.com/python/cpython/issues/76327
|
# original upstream issue: https://github.com/python/cpython/issues/76327
|
||||||
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
|
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import multiprocessing.spawn
|
import multiprocessing.spawn
|
||||||
|
|
||||||
def _freeze_support() -> None:
|
def _freeze_support() -> None:
|
||||||
"""Minimal freeze_support. Only apply this if frozen."""
|
"""Minimal freeze_support. Only apply this if frozen."""
|
||||||
from subprocess import _args_from_interpreter_flags
|
from subprocess import _args_from_interpreter_flags # noqa
|
||||||
|
|
||||||
# Prevent `spawn` from trying to read `__main__` in from the main script
|
# Prevent `spawn` from trying to read `__main__` in from the main script
|
||||||
multiprocessing.process.ORIGINAL_DIR = None
|
multiprocessing.process.ORIGINAL_DIR = None
|
||||||
@@ -896,8 +1014,7 @@ def _extend_freeze_support() -> None:
|
|||||||
# Handle the first process that MP will create
|
# Handle the first process that MP will create
|
||||||
if (
|
if (
|
||||||
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
|
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
|
||||||
'from multiprocessing.semaphore_tracker import main', # Py<3.8
|
'from multiprocessing.resource_tracker import main',
|
||||||
'from multiprocessing.resource_tracker import main', # Py>=3.8
|
|
||||||
'from multiprocessing.forkserver import main'
|
'from multiprocessing.forkserver import main'
|
||||||
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
|
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
|
||||||
):
|
):
|
||||||
@@ -916,20 +1033,26 @@ def _extend_freeze_support() -> None:
|
|||||||
multiprocessing.spawn.spawn_main(**kwargs)
|
multiprocessing.spawn.spawn_main(**kwargs)
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|
||||||
if not is_windows and is_frozen():
|
def _noop() -> None:
|
||||||
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
|
pass
|
||||||
|
|
||||||
|
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
|
||||||
|
|
||||||
|
|
||||||
def freeze_support() -> None:
|
def freeze_support() -> None:
|
||||||
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
|
"""This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load."""
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
_extend_freeze_support()
|
|
||||||
|
deprecate("Use multiprocessing.freeze_support() instead")
|
||||||
multiprocessing.freeze_support()
|
multiprocessing.freeze_support()
|
||||||
|
|
||||||
|
|
||||||
|
_extend_freeze_support()
|
||||||
|
|
||||||
|
|
||||||
def visualize_regions(root_region: Region, file_name: str, *,
|
def visualize_regions(root_region: Region, file_name: str, *,
|
||||||
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
||||||
linetype_ortho: bool = True) -> None:
|
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
|
||||||
"""Visualize the layout of a world as a PlantUML diagram.
|
"""Visualize the layout of a world as a PlantUML diagram.
|
||||||
|
|
||||||
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
|
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
|
||||||
@@ -945,16 +1068,22 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
|||||||
Items without ID will be shown in italics.
|
Items without ID will be shown in italics.
|
||||||
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
|
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
|
||||||
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
|
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
|
||||||
|
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
|
||||||
|
|
||||||
Example usage in World code:
|
Example usage in World code:
|
||||||
from Utils import visualize_regions
|
from Utils import visualize_regions
|
||||||
visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
|
state = self.multiworld.get_all_state(False)
|
||||||
|
state.update_reachable_regions(self.player)
|
||||||
|
visualize_regions(self.get_region("Menu"), "my_world.puml", show_entrance_names=True,
|
||||||
|
regions_to_highlight=state.reachable_regions[self.player])
|
||||||
|
|
||||||
Example usage in Main code:
|
Example usage in Main code:
|
||||||
from Utils import visualize_regions
|
from Utils import visualize_regions
|
||||||
for player in multiworld.player_ids:
|
for player in multiworld.player_ids:
|
||||||
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
|
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
|
||||||
"""
|
"""
|
||||||
|
if regions_to_highlight is None:
|
||||||
|
regions_to_highlight = set()
|
||||||
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
|
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
|
||||||
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
|
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -1007,7 +1136,7 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
|||||||
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
|
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
|
||||||
|
|
||||||
def visualize_region(region: Region) -> None:
|
def visualize_region(region: Region) -> None:
|
||||||
uml.append(f"class \"{fmt(region)}\"")
|
uml.append(f"class \"{fmt(region)}\" {'#00FF00' if region in regions_to_highlight else ''}")
|
||||||
if show_locations:
|
if show_locations:
|
||||||
visualize_locations(region)
|
visualize_locations(region)
|
||||||
visualize_exits(region)
|
visualize_exits(region)
|
||||||
@@ -1056,3 +1185,40 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
|
|||||||
if isinstance(obj, str):
|
if isinstance(obj, str):
|
||||||
return False
|
return False
|
||||||
return isinstance(obj, typing.Iterable)
|
return isinstance(obj, typing.Iterable)
|
||||||
|
|
||||||
|
|
||||||
|
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
|
||||||
|
"""
|
||||||
|
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
|
||||||
|
NOTE: use this with caution because killed threads will not properly clean up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _adjust_thread_count(self):
|
||||||
|
# see upstream ThreadPoolExecutor for details
|
||||||
|
import threading
|
||||||
|
import weakref
|
||||||
|
from concurrent.futures.thread import _worker
|
||||||
|
|
||||||
|
if self._idle_semaphore.acquire(timeout=0):
|
||||||
|
return
|
||||||
|
|
||||||
|
def weakref_cb(_, q=self._work_queue):
|
||||||
|
q.put(None)
|
||||||
|
|
||||||
|
num_threads = len(self._threads)
|
||||||
|
if num_threads < self._max_workers:
|
||||||
|
thread_name = f"{self._thread_name_prefix or self}_{num_threads}"
|
||||||
|
t = threading.Thread(
|
||||||
|
name=thread_name,
|
||||||
|
target=_worker,
|
||||||
|
args=(
|
||||||
|
weakref.ref(self, weakref_cb),
|
||||||
|
self._work_queue,
|
||||||
|
self._initializer,
|
||||||
|
self._initargs,
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
self._threads.add(t)
|
||||||
|
# NOTE: don't add to _threads_queues so we don't block on shutdown
|
||||||
|
|||||||
59
WebHost.py
59
WebHost.py
@@ -34,7 +34,7 @@ def get_app() -> "Flask":
|
|||||||
app.config.from_file(configpath, yaml.safe_load)
|
app.config.from_file(configpath, yaml.safe_load)
|
||||||
logging.info(f"Updated config from {configpath}")
|
logging.info(f"Updated config from {configpath}")
|
||||||
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
|
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||||
parser.add_argument('--config_override', default=None,
|
parser.add_argument('--config_override', default=None,
|
||||||
help="Path to yaml config file that overrules config.yaml.")
|
help="Path to yaml config file that overrules config.yaml.")
|
||||||
args = parser.parse_known_args()[0]
|
args = parser.parse_known_args()[0]
|
||||||
@@ -54,16 +54,15 @@ def get_app() -> "Flask":
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
def copy_tutorials_files_to_static() -> None:
|
||||||
import json
|
|
||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
zfile: zipfile.ZipInfo
|
zfile: zipfile.ZipInfo
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
worlds = {}
|
worlds = {}
|
||||||
data = []
|
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
||||||
worlds[game] = world
|
worlds[game] = world
|
||||||
@@ -72,7 +71,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
|||||||
shutil.rmtree(base_target_path, ignore_errors=True)
|
shutil.rmtree(base_target_path, ignore_errors=True)
|
||||||
for game, world in worlds.items():
|
for game, world in worlds.items():
|
||||||
# copy files from world's docs folder to the generated folder
|
# copy files from world's docs folder to the generated folder
|
||||||
target_path = os.path.join(base_target_path, get_file_safe_name(game))
|
target_path = os.path.join(base_target_path, secure_filename(game))
|
||||||
os.makedirs(target_path, exist_ok=True)
|
os.makedirs(target_path, exist_ok=True)
|
||||||
|
|
||||||
if world.zip_path:
|
if world.zip_path:
|
||||||
@@ -85,45 +84,14 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
|||||||
for zfile in zf.infolist():
|
for zfile in zf.infolist():
|
||||||
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
||||||
zfile.filename = os.path.basename(zfile.filename)
|
zfile.filename = os.path.basename(zfile.filename)
|
||||||
zf.extract(zfile, target_path)
|
with open(os.path.join(target_path, secure_filename(zfile.filename)), "wb") as f:
|
||||||
|
f.write(zf.read(zfile))
|
||||||
else:
|
else:
|
||||||
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
||||||
files = os.listdir(source_path)
|
files = os.listdir(source_path)
|
||||||
for file in files:
|
for file in files:
|
||||||
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
|
shutil.copyfile(Utils.local_path(source_path, file),
|
||||||
|
Utils.local_path(target_path, secure_filename(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__":
|
if __name__ == "__main__":
|
||||||
@@ -131,18 +99,25 @@ if __name__ == "__main__":
|
|||||||
multiprocessing.set_start_method('spawn')
|
multiprocessing.set_start_method('spawn')
|
||||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||||
|
|
||||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
|
||||||
from WebHostLib.autolauncher import autohost, autogen, stop
|
from WebHostLib.autolauncher import autohost, autogen, stop
|
||||||
from WebHostLib.options import create as create_options_files
|
from WebHostLib.options import create as create_options_files
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||||
update_sprites_lttp()
|
update_sprites_lttp()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
logging.warning("Could not update LttP sprites.")
|
logging.warning("Could not update LttP sprites.")
|
||||||
app = get_app()
|
app = get_app()
|
||||||
|
from worlds import AutoWorldRegister
|
||||||
|
# Update to only valid WebHost worlds
|
||||||
|
invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items()
|
||||||
|
if not hasattr(world.web, "tutorials")}
|
||||||
|
if invalid_worlds:
|
||||||
|
logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}")
|
||||||
|
AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds}
|
||||||
create_options_files()
|
create_options_files()
|
||||||
create_ordered_tutorials_file()
|
copy_tutorials_files_to_static()
|
||||||
if app.config["SELFLAUNCH"]:
|
if app.config["SELFLAUNCH"]:
|
||||||
autohost(app.config)
|
autohost(app.config)
|
||||||
if app.config["SELFGEN"]:
|
if app.config["SELFGEN"]:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import typing
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -39,6 +40,8 @@ app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
|||||||
app.config["JOB_THRESHOLD"] = 1
|
app.config["JOB_THRESHOLD"] = 1
|
||||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
# 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["JOB_TIME"] = 600
|
||||||
|
# memory limit for generator processes in bytes
|
||||||
|
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
|
||||||
app.config['SESSION_PERMANENT'] = True
|
app.config['SESSION_PERMANENT'] = True
|
||||||
|
|
||||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||||
@@ -59,32 +62,44 @@ cache = Cache()
|
|||||||
Compress(app)
|
Compress(app)
|
||||||
|
|
||||||
|
|
||||||
|
def to_python(value: str) -> uuid.UUID:
|
||||||
|
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
||||||
|
|
||||||
|
|
||||||
|
def to_url(value: uuid.UUID) -> str:
|
||||||
|
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
class B64UUIDConverter(BaseConverter):
|
class B64UUIDConverter(BaseConverter):
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value: str) -> uuid.UUID:
|
||||||
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
return to_python(value)
|
||||||
|
|
||||||
def to_url(self, value):
|
def to_url(self, value: typing.Any) -> str:
|
||||||
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
assert isinstance(value, uuid.UUID)
|
||||||
|
return to_url(value)
|
||||||
|
|
||||||
|
|
||||||
# short UUID
|
# short UUID
|
||||||
app.url_map.converters["suuid"] = B64UUIDConverter
|
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["suuid"] = to_url
|
||||||
app.jinja_env.filters["title_sorted"] = title_sorted
|
app.jinja_env.filters["title_sorted"] = title_sorted
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register() -> None:
|
||||||
"""Import submodules, triggering their registering on flask routing.
|
"""Import submodules, triggering their registering on flask routing.
|
||||||
Note: initializes worlds subsystem."""
|
Note: initializes worlds subsystem."""
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from werkzeug.utils import find_modules
|
||||||
# has automatic patch integration
|
# has automatic patch integration
|
||||||
import worlds.AutoWorld
|
|
||||||
import worlds.Files
|
import worlds.Files
|
||||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
|
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
|
||||||
game_name in worlds.Files.AutoPatchRegister.patch_types
|
|
||||||
|
|
||||||
from WebHostLib.customserver import run_server_process
|
from WebHostLib.customserver import run_server_process
|
||||||
# to trigger app routing picking up on it
|
|
||||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options
|
|
||||||
|
|
||||||
|
for module in find_modules("WebHostLib", include_packages=True):
|
||||||
|
importlib.import_module(module)
|
||||||
|
|
||||||
|
from . import api
|
||||||
app.register_blueprint(api.api_endpoints)
|
app.register_blueprint(api.api_endpoints)
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ from typing import List, Tuple
|
|||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
|
||||||
from ..models import Seed
|
from ..models import Seed, Slot
|
||||||
|
|
||||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||||
return [(slot.player_name, slot.game) for slot in seed.slots]
|
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
|
||||||
|
|
||||||
|
# trigger endpoint registration
|
||||||
from . import datapackage, generate, room, user # trigger registration
|
from . import datapackage, generate, room, tracker, user
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
import pickle
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from flask import request, session, url_for
|
from flask import request, session, url_for
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from pony.orm import commit
|
from pony.orm import commit
|
||||||
|
|
||||||
|
from Utils import restricted_dumps
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from WebHostLib.check import get_yaml_data, roll_options
|
from WebHostLib.check import get_yaml_data, roll_options
|
||||||
from WebHostLib.generate import get_meta
|
from WebHostLib.generate import get_meta
|
||||||
@@ -56,7 +56,7 @@ def generate_api():
|
|||||||
"detail": results}, 400
|
"detail": results}, 400
|
||||||
else:
|
else:
|
||||||
gen = Generation(
|
gen = Generation(
|
||||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||||
# convert to json compatible
|
# convert to json compatible
|
||||||
meta=json.dumps(meta), state=STATE_QUEUED,
|
meta=json.dumps(meta), state=STATE_QUEUED,
|
||||||
owner=session["_id"])
|
owner=session["_id"])
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from flask import abort, url_for
|
from flask import abort, url_for
|
||||||
|
|
||||||
|
from WebHostLib import to_url
|
||||||
import worlds.Files
|
import worlds.Files
|
||||||
from . import api_endpoints, get_players
|
from . import api_endpoints, get_players
|
||||||
from ..models import Room
|
from ..models import Room
|
||||||
@@ -33,7 +34,7 @@ def room_info(room_id: UUID) -> Dict[str, Any]:
|
|||||||
downloads.append(slot_download)
|
downloads.append(slot_download)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tracker": room.tracker,
|
"tracker": to_url(room.tracker),
|
||||||
"players": get_players(room.seed),
|
"players": get_players(room.seed),
|
||||||
"last_port": room.last_port,
|
"last_port": room.last_port,
|
||||||
"last_activity": room.last_activity,
|
"last_activity": room.last_activity,
|
||||||
|
|||||||
258
WebHostLib/api/tracker.py
Normal file
258
WebHostLib/api/tracker.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, TypedDict
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from flask import abort
|
||||||
|
|
||||||
|
from NetUtils import ClientStatus, Hint, NetworkItem, SlotType
|
||||||
|
from WebHostLib import cache
|
||||||
|
from WebHostLib.api import api_endpoints
|
||||||
|
from WebHostLib.models import Room
|
||||||
|
from WebHostLib.tracker import TrackerData
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerAlias(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
alias: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerItemsReceived(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
items: list[NetworkItem]
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerChecksDone(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
locations: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
class TeamTotalChecks(TypedDict):
|
||||||
|
team: int
|
||||||
|
checks_done: int
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerHints(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
hints: list[Hint]
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerTimer(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
time: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerStatus(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
status: ClientStatus
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerLocationsTotal(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
total_locations: int
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerGame(TypedDict):
|
||||||
|
team: int
|
||||||
|
player: int
|
||||||
|
game: str
|
||||||
|
|
||||||
|
|
||||||
|
@api_endpoints.route("/tracker/<suuid:tracker>")
|
||||||
|
@cache.memoize(timeout=60)
|
||||||
|
def tracker_data(tracker: UUID) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Outputs json data to <root_path>/api/tracker/<id of current session tracker>.
|
||||||
|
|
||||||
|
:param tracker: UUID of current session tracker.
|
||||||
|
|
||||||
|
:return: Tracking data for all players in the room. Typing and docstrings describe the format of each value.
|
||||||
|
"""
|
||||||
|
room: Room | None = Room.get(tracker=tracker)
|
||||||
|
if not room:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
tracker_data = TrackerData(room)
|
||||||
|
|
||||||
|
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
||||||
|
|
||||||
|
player_aliases: list[PlayerAlias] = []
|
||||||
|
"""Slot aliases of all players."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
player_aliases.append(
|
||||||
|
{"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
|
||||||
|
|
||||||
|
player_items_received: list[PlayerItemsReceived] = []
|
||||||
|
"""Items received by each player."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
player_items_received.append(
|
||||||
|
{"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)})
|
||||||
|
|
||||||
|
player_checks_done: list[PlayerChecksDone] = []
|
||||||
|
"""ID of all locations checked by each player."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
player_checks_done.append(
|
||||||
|
{"team": team, "player": player,
|
||||||
|
"locations": sorted(tracker_data.get_player_checked_locations(team, player))})
|
||||||
|
|
||||||
|
total_checks_done: list[TeamTotalChecks] = [
|
||||||
|
{"team": team, "checks_done": checks_done}
|
||||||
|
for team, checks_done in tracker_data.get_team_locations_checked_count().items()
|
||||||
|
]
|
||||||
|
"""Total number of locations checked for the entire multiworld per team."""
|
||||||
|
|
||||||
|
hints: list[PlayerHints] = []
|
||||||
|
"""Hints that all players have used or received."""
|
||||||
|
for team, players in tracker_data.get_all_slots().items():
|
||||||
|
for player in players:
|
||||||
|
player_hints = sorted(tracker_data.get_player_hints(team, player))
|
||||||
|
hints.append({"team": team, "player": player, "hints": player_hints})
|
||||||
|
slot_info = tracker_data.get_slot_info(player)
|
||||||
|
# this assumes groups are always after players
|
||||||
|
if slot_info.type != SlotType.group:
|
||||||
|
continue
|
||||||
|
for member in slot_info.group_members:
|
||||||
|
hints[member - 1]["hints"] += player_hints
|
||||||
|
|
||||||
|
activity_timers: list[PlayerTimer] = []
|
||||||
|
"""Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
activity_timers.append({"team": team, "player": player, "time": None})
|
||||||
|
|
||||||
|
for (team, player), timestamp in tracker_data._multisave.get("client_activity_timers", []):
|
||||||
|
for entry in activity_timers:
|
||||||
|
if entry["team"] == team and entry["player"] == player:
|
||||||
|
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||||
|
break
|
||||||
|
|
||||||
|
connection_timers: list[PlayerTimer] = []
|
||||||
|
"""Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
connection_timers.append({"team": team, "player": player, "time": None})
|
||||||
|
|
||||||
|
for (team, player), timestamp in tracker_data._multisave.get("client_connection_timers", []):
|
||||||
|
# find the matching entry
|
||||||
|
for entry in connection_timers:
|
||||||
|
if entry["team"] == team and entry["player"] == player:
|
||||||
|
entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||||
|
break
|
||||||
|
|
||||||
|
player_status: list[PlayerStatus] = []
|
||||||
|
"""The current client status for each player."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
player_status.append(
|
||||||
|
{"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"aliases": player_aliases,
|
||||||
|
"player_items_received": player_items_received,
|
||||||
|
"player_checks_done": player_checks_done,
|
||||||
|
"total_checks_done": total_checks_done,
|
||||||
|
"hints": hints,
|
||||||
|
"activity_timers": activity_timers,
|
||||||
|
"connection_timers": connection_timers,
|
||||||
|
"player_status": player_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerGroups(TypedDict):
|
||||||
|
slot: int
|
||||||
|
name: str
|
||||||
|
members: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerSlotData(TypedDict):
|
||||||
|
player: int
|
||||||
|
slot_data: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@api_endpoints.route("/static_tracker/<suuid:tracker>")
|
||||||
|
@cache.memoize(timeout=300)
|
||||||
|
def static_tracker_data(tracker: UUID) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Outputs json data to <root_path>/api/static_tracker/<id of current session tracker>.
|
||||||
|
|
||||||
|
:param tracker: UUID of current session tracker.
|
||||||
|
|
||||||
|
:return: Static tracking data for all players in the room. Typing and docstrings describe the format of each value.
|
||||||
|
"""
|
||||||
|
room: Room | None = Room.get(tracker=tracker)
|
||||||
|
if not room:
|
||||||
|
abort(404)
|
||||||
|
tracker_data = TrackerData(room)
|
||||||
|
|
||||||
|
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
||||||
|
|
||||||
|
groups: list[PlayerGroups] = []
|
||||||
|
"""The Slot ID of groups and the IDs of the group's members."""
|
||||||
|
for team, players in tracker_data.get_all_slots().items():
|
||||||
|
for player in players:
|
||||||
|
slot_info = tracker_data.get_slot_info(player)
|
||||||
|
if slot_info.type != SlotType.group or not slot_info.group_members:
|
||||||
|
continue
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"slot": player,
|
||||||
|
"name": slot_info.name,
|
||||||
|
"members": list(slot_info.group_members),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
player_locations_total: list[PlayerLocationsTotal] = []
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
player_locations_total.append(
|
||||||
|
{"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
|
||||||
|
|
||||||
|
player_game: list[PlayerGame] = []
|
||||||
|
"""The played game per player slot."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
player_game.append({"team": team, "player": player, "game": tracker_data.get_player_game(player)})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"groups": groups,
|
||||||
|
"datapackage": tracker_data._multidata["datapackage"],
|
||||||
|
"player_locations_total": player_locations_total,
|
||||||
|
"player_game": player_game,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# It should be exceedingly rare that slot data is needed, so it's separated out.
|
||||||
|
@api_endpoints.route("/slot_data_tracker/<suuid:tracker>")
|
||||||
|
@cache.memoize(timeout=300)
|
||||||
|
def tracker_slot_data(tracker: UUID) -> list[PlayerSlotData]:
|
||||||
|
"""
|
||||||
|
Outputs json data to <root_path>/api/slot_data_tracker/<id of current session tracker>.
|
||||||
|
|
||||||
|
:param tracker: UUID of current session tracker.
|
||||||
|
|
||||||
|
:return: Slot data for all players in the room. Typing completely arbitrary per game.
|
||||||
|
"""
|
||||||
|
room: Room | None = Room.get(tracker=tracker)
|
||||||
|
if not room:
|
||||||
|
abort(404)
|
||||||
|
tracker_data = TrackerData(room)
|
||||||
|
|
||||||
|
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
||||||
|
|
||||||
|
slot_data: list[PlayerSlotData] = []
|
||||||
|
"""Slot data for each player."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
for player in players:
|
||||||
|
slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(player)})
|
||||||
|
break
|
||||||
|
|
||||||
|
return slot_data
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import session, jsonify
|
from flask import session, jsonify
|
||||||
from pony.orm import select
|
from pony.orm import select
|
||||||
|
|
||||||
|
from WebHostLib import to_url
|
||||||
from WebHostLib.models import Room, Seed
|
from WebHostLib.models import Room, Seed
|
||||||
from . import api_endpoints, get_players
|
from . import api_endpoints, get_players
|
||||||
|
|
||||||
@@ -10,13 +11,13 @@ def get_rooms():
|
|||||||
response = []
|
response = []
|
||||||
for room in select(room for room in Room if room.owner == session["_id"]):
|
for room in select(room for room in Room if room.owner == session["_id"]):
|
||||||
response.append({
|
response.append({
|
||||||
"room_id": room.id,
|
"room_id": to_url(room.id),
|
||||||
"seed_id": room.seed.id,
|
"seed_id": to_url(room.seed.id),
|
||||||
"creation_time": room.creation_time,
|
"creation_time": room.creation_time,
|
||||||
"last_activity": room.last_activity,
|
"last_activity": room.last_activity,
|
||||||
"last_port": room.last_port,
|
"last_port": room.last_port,
|
||||||
"timeout": room.timeout,
|
"timeout": room.timeout,
|
||||||
"tracker": room.tracker,
|
"tracker": to_url(room.tracker),
|
||||||
})
|
})
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|
||||||
@@ -26,8 +27,8 @@ def get_seeds():
|
|||||||
response = []
|
response = []
|
||||||
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
|
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
|
||||||
response.append({
|
response.append({
|
||||||
"seed_id": seed.id,
|
"seed_id": to_url(seed.id),
|
||||||
"creation_time": seed.creation_time,
|
"creation_time": seed.creation_time,
|
||||||
"players": get_players(seed.slots),
|
"players": get_players(seed),
|
||||||
})
|
})
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import multiprocessing
|
|||||||
import typing
|
import typing
|
||||||
from datetime import timedelta, datetime
|
from datetime import timedelta, datetime
|
||||||
from threading import Event, Thread
|
from threading import Event, Thread
|
||||||
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pony.orm import db_session, select, commit
|
from pony.orm import db_session, select, commit, PrimaryKey
|
||||||
|
|
||||||
from Utils import restricted_loads
|
from Utils import restricted_loads
|
||||||
from .locker import Locker, AlreadyRunningException
|
from .locker import Locker, AlreadyRunningException
|
||||||
@@ -16,7 +17,7 @@ from .locker import Locker, AlreadyRunningException
|
|||||||
_stop_event = Event()
|
_stop_event = Event()
|
||||||
|
|
||||||
|
|
||||||
def stop():
|
def stop() -> None:
|
||||||
"""Stops previously launched threads"""
|
"""Stops previously launched threads"""
|
||||||
global _stop_event
|
global _stop_event
|
||||||
stop_event = _stop_event
|
stop_event = _stop_event
|
||||||
@@ -35,16 +36,39 @@ def handle_generation_failure(result: BaseException):
|
|||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
|
|
||||||
|
|
||||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
def _mp_gen_game(
|
||||||
|
gen_options: dict,
|
||||||
|
meta: dict[str, Any] | None = None,
|
||||||
|
owner=None,
|
||||||
|
sid=None,
|
||||||
|
timeout: int|None = None,
|
||||||
|
) -> PrimaryKey | None:
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
|
setproctitle(f"Generator ({sid})")
|
||||||
|
try:
|
||||||
|
return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout)
|
||||||
|
finally:
|
||||||
|
setproctitle(f"Generator (idle)")
|
||||||
|
|
||||||
|
|
||||||
|
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None:
|
||||||
try:
|
try:
|
||||||
meta = json.loads(generation.meta)
|
meta = json.loads(generation.meta)
|
||||||
options = restricted_loads(generation.options)
|
options = restricted_loads(generation.options)
|
||||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||||
pool.apply_async(gen_game, (options,),
|
pool.apply_async(
|
||||||
{"meta": meta,
|
_mp_gen_game,
|
||||||
"sid": generation.id,
|
(options,),
|
||||||
"owner": generation.owner},
|
{
|
||||||
handle_generation_success, handle_generation_failure)
|
"meta": meta,
|
||||||
|
"sid": generation.id,
|
||||||
|
"owner": generation.owner,
|
||||||
|
"timeout": timeout,
|
||||||
|
},
|
||||||
|
handle_generation_success,
|
||||||
|
handle_generation_failure,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
generation.state = STATE_ERROR
|
generation.state = STATE_ERROR
|
||||||
commit()
|
commit()
|
||||||
@@ -53,7 +77,25 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
|||||||
generation.state = STATE_STARTED
|
generation.state = STATE_STARTED
|
||||||
|
|
||||||
|
|
||||||
def init_db(pony_config: dict):
|
def init_generator(config: dict[str, Any]) -> None:
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
|
setproctitle("Generator (idle)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import resource
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass # unix only module
|
||||||
|
else:
|
||||||
|
# set soft limit for memory to from config (default 4GiB)
|
||||||
|
soft_limit = config["GENERATOR_MEMORY_LIMIT"]
|
||||||
|
old_limit, hard_limit = resource.getrlimit(resource.RLIMIT_AS)
|
||||||
|
if soft_limit != old_limit:
|
||||||
|
resource.setrlimit(resource.RLIMIT_AS, (soft_limit, hard_limit))
|
||||||
|
logging.debug(f"Changed AS mem limit {old_limit} -> {soft_limit}")
|
||||||
|
del resource, soft_limit, hard_limit
|
||||||
|
|
||||||
|
pony_config = config["PONY"]
|
||||||
db.bind(**pony_config)
|
db.bind(**pony_config)
|
||||||
db.generate_mapping()
|
db.generate_mapping()
|
||||||
|
|
||||||
@@ -105,8 +147,9 @@ def autogen(config: dict):
|
|||||||
try:
|
try:
|
||||||
with Locker("autogen"):
|
with Locker("autogen"):
|
||||||
|
|
||||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
|
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
||||||
initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
|
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
||||||
|
job_time = config["JOB_TIME"]
|
||||||
with db_session:
|
with db_session:
|
||||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||||
|
|
||||||
@@ -117,7 +160,7 @@ def autogen(config: dict):
|
|||||||
if sid:
|
if sid:
|
||||||
generation.delete()
|
generation.delete()
|
||||||
else:
|
else:
|
||||||
launch_generator(generator_pool, generation)
|
launch_generator(generator_pool, generation, timeout=job_time)
|
||||||
|
|
||||||
commit()
|
commit()
|
||||||
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
||||||
@@ -129,16 +172,13 @@ def autogen(config: dict):
|
|||||||
generation for generation in Generation
|
generation for generation in Generation
|
||||||
if generation.state == STATE_QUEUED).for_update()
|
if generation.state == STATE_QUEUED).for_update()
|
||||||
for generation in to_start:
|
for generation in to_start:
|
||||||
launch_generator(generator_pool, generation)
|
launch_generator(generator_pool, generation, timeout=job_time)
|
||||||
except AlreadyRunningException:
|
except AlreadyRunningException:
|
||||||
logging.info("Autogen reports as already running, not starting another.")
|
logging.info("Autogen reports as already running, not starting another.")
|
||||||
|
|
||||||
Thread(target=keep_running, name="AP_Autogen").start()
|
Thread(target=keep_running, name="AP_Autogen").start()
|
||||||
|
|
||||||
|
|
||||||
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class MultiworldInstance():
|
class MultiworldInstance():
|
||||||
def __init__(self, config: dict, id: int):
|
def __init__(self, config: dict, id: int):
|
||||||
self.room_ids = set()
|
self.room_ids = set()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
import base64
|
import base64
|
||||||
from typing import Union, Dict, Set, Tuple
|
from collections.abc import Set
|
||||||
|
|
||||||
from flask import request, flash, redirect, url_for, render_template
|
from flask import request, flash, redirect, url_for, render_template
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
@@ -43,7 +43,7 @@ def mysterycheck():
|
|||||||
return redirect(url_for("check"), 301)
|
return redirect(url_for("check"), 301)
|
||||||
|
|
||||||
|
|
||||||
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
|
def get_yaml_data(files) -> dict[str, str] | str | Markup:
|
||||||
options = {}
|
options = {}
|
||||||
for uploaded_file in files:
|
for uploaded_file in files:
|
||||||
if banned_file(uploaded_file.filename):
|
if banned_file(uploaded_file.filename):
|
||||||
@@ -84,12 +84,12 @@ def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
def roll_options(options: Dict[str, Union[dict, str]],
|
def roll_options(options: dict[str, dict | str],
|
||||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
tuple[dict[str, str | bool], dict[str, dict]]:
|
||||||
plando_options = PlandoOptions.from_set(set(plando_options))
|
plando_options = PlandoOptions.from_set(set(plando_options))
|
||||||
results = {}
|
results: dict[str, str | bool] = {}
|
||||||
rolled_results = {}
|
rolled_results: dict[str, dict] = {}
|
||||||
for filename, text in options.items():
|
for filename, text in options.items():
|
||||||
try:
|
try:
|
||||||
if type(text) is dict:
|
if type(text) is dict:
|
||||||
@@ -105,8 +105,9 @@ def roll_options(options: Dict[str, Union[dict, str]],
|
|||||||
plando_options=plando_options)
|
plando_options=plando_options)
|
||||||
else:
|
else:
|
||||||
for i, yaml_data in enumerate(yaml_datas):
|
for i, yaml_data in enumerate(yaml_datas):
|
||||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
if yaml_data is not None:
|
||||||
plando_options=plando_options)
|
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||||
|
plando_options=plando_options)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if e.__cause__:
|
if e.__cause__:
|
||||||
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
|
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ from pony.orm import commit, db_session, select
|
|||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
from MultiServer import (
|
||||||
|
Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert,
|
||||||
|
server_per_message_deflate_factory,
|
||||||
|
)
|
||||||
from Utils import restricted_loads, cache_argsless
|
from Utils import restricted_loads, cache_argsless
|
||||||
from .locker import Locker
|
from .locker import Locker
|
||||||
from .models import Command, GameDataPackage, Room, db
|
from .models import Command, GameDataPackage, Room, db
|
||||||
@@ -97,6 +100,7 @@ class WebHostContext(Context):
|
|||||||
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
|
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
|
||||||
command.delete()
|
command.delete()
|
||||||
commit()
|
commit()
|
||||||
|
del commands
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
|
|
||||||
@db_session
|
@db_session
|
||||||
@@ -117,6 +121,7 @@ class WebHostContext(Context):
|
|||||||
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
|
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
|
||||||
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
|
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
|
||||||
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
|
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
|
||||||
|
missing_checksum = False
|
||||||
|
|
||||||
for game in list(multidata.get("datapackage", {})):
|
for game in list(multidata.get("datapackage", {})):
|
||||||
game_data = multidata["datapackage"][game]
|
game_data = multidata["datapackage"][game]
|
||||||
@@ -128,34 +133,37 @@ class WebHostContext(Context):
|
|||||||
else:
|
else:
|
||||||
row = GameDataPackage.get(checksum=game_data["checksum"])
|
row = GameDataPackage.get(checksum=game_data["checksum"])
|
||||||
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
||||||
game_data_packages[game] = Utils.restricted_loads(row.data)
|
game_data_packages[game] = restricted_loads(row.data)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
||||||
|
else:
|
||||||
|
missing_checksum = True # Game rolled on old AP and will load data package from multidata
|
||||||
self.gamespackage[game] = static_gamespackage.get(game, {})
|
self.gamespackage[game] = static_gamespackage.get(game, {})
|
||||||
self.item_name_groups[game] = static_item_name_groups.get(game, {})
|
self.item_name_groups[game] = static_item_name_groups.get(game, {})
|
||||||
self.location_name_groups[game] = static_location_name_groups.get(game, {})
|
self.location_name_groups[game] = static_location_name_groups.get(game, {})
|
||||||
|
|
||||||
if not game_data_packages:
|
if not game_data_packages and not missing_checksum:
|
||||||
# all static -> use the static dicts directly
|
# all static -> use the static dicts directly
|
||||||
self.gamespackage = static_gamespackage
|
self.gamespackage = static_gamespackage
|
||||||
self.item_name_groups = static_item_name_groups
|
self.item_name_groups = static_item_name_groups
|
||||||
self.location_name_groups = static_location_name_groups
|
self.location_name_groups = static_location_name_groups
|
||||||
return self._load(multidata, game_data_packages, True)
|
return self._load(multidata, game_data_packages, True)
|
||||||
|
|
||||||
@db_session
|
|
||||||
def init_save(self, enabled: bool = True):
|
def init_save(self, enabled: bool = True):
|
||||||
self.saving = enabled
|
self.saving = enabled
|
||||||
if self.saving:
|
if self.saving:
|
||||||
savegame_data = Room.get(id=self.room_id).multisave
|
with db_session:
|
||||||
if savegame_data:
|
savegame_data = Room.get(id=self.room_id).multisave
|
||||||
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
|
if savegame_data:
|
||||||
|
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
|
||||||
self._start_async_saving(atexit_save=False)
|
self._start_async_saving(atexit_save=False)
|
||||||
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
||||||
|
|
||||||
@db_session
|
@db_session
|
||||||
def _save(self, exit_save: bool = False) -> bool:
|
def _save(self, exit_save: bool = False) -> bool:
|
||||||
room = Room.get(id=self.room_id)
|
room = Room.get(id=self.room_id)
|
||||||
|
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
|
||||||
room.multisave = pickle.dumps(self.get_save())
|
room.multisave = pickle.dumps(self.get_save())
|
||||||
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
||||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||||
@@ -224,6 +232,9 @@ def set_up_logging(room_id) -> logging.Logger:
|
|||||||
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||||
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
|
setproctitle(name)
|
||||||
Utils.init_logging(name)
|
Utils.init_logging(name)
|
||||||
try:
|
try:
|
||||||
import resource
|
import resource
|
||||||
@@ -244,8 +255,23 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
raise Exception("Worlds system should not be loaded in the custom server.")
|
raise Exception("Worlds system should not be loaded in the custom server.")
|
||||||
|
|
||||||
import gc
|
import gc
|
||||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
|
||||||
del cert_file, cert_key_file, ponyconfig
|
if not cert_file:
|
||||||
|
def get_ssl_context():
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
load_date = None
|
||||||
|
ssl_context = load_server_cert(cert_file, cert_key_file)
|
||||||
|
|
||||||
|
def get_ssl_context():
|
||||||
|
nonlocal load_date, ssl_context
|
||||||
|
today = datetime.date.today()
|
||||||
|
if load_date != today:
|
||||||
|
ssl_context = load_server_cert(cert_file, cert_key_file)
|
||||||
|
load_date = today
|
||||||
|
return ssl_context
|
||||||
|
|
||||||
|
del ponyconfig
|
||||||
gc.collect() # free intermediate objects used during setup
|
gc.collect() # free intermediate objects used during setup
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
@@ -260,12 +286,16 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
assert ctx.server is None
|
assert ctx.server is None
|
||||||
try:
|
try:
|
||||||
ctx.server = websockets.serve(
|
ctx.server = websockets.serve(
|
||||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
functools.partial(server, ctx=ctx),
|
||||||
|
ctx.host,
|
||||||
|
ctx.port,
|
||||||
|
ssl=get_ssl_context(),
|
||||||
|
extensions=[server_per_message_deflate_factory],
|
||||||
|
)
|
||||||
await ctx.server
|
await ctx.server
|
||||||
except OSError: # likely port in use
|
except OSError: # likely port in use
|
||||||
ctx.server = websockets.serve(
|
ctx.server = websockets.serve(
|
||||||
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
port = 0
|
port = 0
|
||||||
@@ -282,6 +312,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
with db_session:
|
with db_session:
|
||||||
room = Room.get(id=ctx.room_id)
|
room = Room.get(id=ctx.room_id)
|
||||||
room.last_port = port
|
room.last_port = port
|
||||||
|
del room
|
||||||
else:
|
else:
|
||||||
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
||||||
with db_session:
|
with db_session:
|
||||||
@@ -300,6 +331,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
with db_session:
|
with db_session:
|
||||||
room = Room.get(id=room_id)
|
room = Room.get(id=room_id)
|
||||||
room.last_port = -1
|
room.last_port = -1
|
||||||
|
del room
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
@@ -311,11 +343,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
|
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
|
||||||
ctx.exit_event.set() # make sure the saving thread stops at some point
|
ctx.exit_event.set() # make sure the saving thread stops at some point
|
||||||
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
|
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
|
||||||
with (db_session):
|
with db_session:
|
||||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||||
room = Room.get(id=room_id)
|
room = Room.get(id=room_id)
|
||||||
room.last_activity = datetime.datetime.utcnow() - \
|
room.last_activity = datetime.datetime.utcnow() - \
|
||||||
datetime.timedelta(minutes=1, seconds=room.timeout)
|
datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||||
|
del room
|
||||||
logging.info(f"Shutting down room {room_id} on {name}.")
|
logging.info(f"Shutting down room {room_id} on {name}.")
|
||||||
finally:
|
finally:
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|||||||
@@ -61,12 +61,7 @@ def download_slot_file(room_id, player_id: int):
|
|||||||
else:
|
else:
|
||||||
import io
|
import io
|
||||||
|
|
||||||
if slot_data.game == "Minecraft":
|
if slot_data.game == "Factorio":
|
||||||
from worlds.minecraft import mc_update_output
|
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
|
||||||
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
|
|
||||||
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
|
|
||||||
elif slot_data.game == "Factorio":
|
|
||||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||||
for name in zf.namelist():
|
for name in zf.namelist():
|
||||||
if name.endswith("info.json"):
|
if name.endswith("info.json"):
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pickle
|
|
||||||
import random
|
import random
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Any, Dict, List, Optional, Union, Set
|
from pickle import PicklingError
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from flask import flash, redirect, render_template, request, session, url_for
|
from flask import flash, redirect, render_template, request, session, url_for
|
||||||
from pony.orm import commit, db_session
|
from pony.orm import commit, db_session
|
||||||
|
|
||||||
from BaseClasses import get_seed, seeddigits
|
from BaseClasses import get_seed, seeddigits
|
||||||
from Generate import PlandoOptions, handle_name
|
from Generate import PlandoOptions, handle_name, mystery_argparse
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from Utils import __version__
|
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from settings import ServerOptions, GeneratorOptions
|
from settings import ServerOptions, GeneratorOptions
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
|
||||||
from .check import get_yaml_data, roll_options
|
from .check import get_yaml_data, roll_options
|
||||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
||||||
from .upload import upload_zip_to_db
|
from .upload import upload_zip_to_db
|
||||||
|
|
||||||
|
|
||||||
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
|
def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | dict[str, Any]]:
|
||||||
plando_options: Set[str] = set()
|
plando_options: set[str] = set()
|
||||||
for substr in ("bosses", "items", "connections", "texts"):
|
for substr in ("bosses", "items", "connections", "texts"):
|
||||||
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
|
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
|
||||||
plando_options.add(substr)
|
plando_options.add(substr)
|
||||||
|
|
||||||
server_options = {
|
server_options = {
|
||||||
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)),
|
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)),
|
||||||
"release_mode": options_source.get("release_mode", ServerOptions.release_mode),
|
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
|
||||||
"remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode),
|
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
|
||||||
"collect_mode": options_source.get("collect_mode", ServerOptions.collect_mode),
|
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
|
||||||
|
"countdown_mode": str(options_source.get("countdown_mode", ServerOptions.countdown_mode)),
|
||||||
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
||||||
"server_password": options_source.get("server_password", None),
|
"server_password": str(options_source.get("server_password", None)),
|
||||||
}
|
}
|
||||||
generator_options = {
|
generator_options = {
|
||||||
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),
|
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),
|
||||||
@@ -73,7 +73,11 @@ def generate(race=False):
|
|||||||
return render_template("generate.html", race=race, version=__version__)
|
return render_template("generate.html", race=race, version=__version__)
|
||||||
|
|
||||||
|
|
||||||
def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
|
def format_exception(e: BaseException) -> str:
|
||||||
|
return f"{e.__class__.__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
||||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||||
|
|
||||||
if any(type(result) == str for result in results.values()):
|
if any(type(result) == str for result in results.values()):
|
||||||
@@ -83,30 +87,40 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any])
|
|||||||
f"If you have a larger group, please generate it yourself and upload it.")
|
f"If you have a larger group, please generate it yourself and upload it.")
|
||||||
return redirect(url_for(request.endpoint, **(request.view_args or {})))
|
return redirect(url_for(request.endpoint, **(request.view_args or {})))
|
||||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||||
gen = Generation(
|
try:
|
||||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
gen = Generation(
|
||||||
# convert to json compatible
|
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||||
meta=json.dumps(meta),
|
# convert to json compatible
|
||||||
state=STATE_QUEUED,
|
meta=json.dumps(meta),
|
||||||
owner=session["_id"])
|
state=STATE_QUEUED,
|
||||||
|
owner=session["_id"])
|
||||||
|
except PicklingError as e:
|
||||||
|
from .autolauncher import handle_generation_failure
|
||||||
|
handle_generation_failure(e)
|
||||||
|
meta["error"] = format_exception(e)
|
||||||
|
details = json.dumps(meta, indent=4).strip()
|
||||||
|
return render_template("seedError.html", seed_error=meta["error"], details=details)
|
||||||
|
|
||||||
commit()
|
commit()
|
||||||
|
|
||||||
return redirect(url_for("wait_seed", seed=gen.id))
|
return redirect(url_for("wait_seed", seed=gen.id))
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
||||||
meta=meta, owner=session["_id"].int)
|
meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"])
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
from .autolauncher import handle_generation_failure
|
from .autolauncher import handle_generation_failure
|
||||||
handle_generation_failure(e)
|
handle_generation_failure(e)
|
||||||
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
|
meta["error"] = format_exception(e)
|
||||||
|
details = json.dumps(meta, indent=4).strip()
|
||||||
|
return render_template("seedError.html", seed_error=meta["error"], details=details)
|
||||||
|
|
||||||
return redirect(url_for("view_seed", seed=seed_id))
|
return redirect(url_for("view_seed", seed=seed_id))
|
||||||
|
|
||||||
|
|
||||||
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None):
|
||||||
if not meta:
|
if meta is None:
|
||||||
meta: Dict[str, Any] = {}
|
meta = {}
|
||||||
|
|
||||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||||
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
||||||
@@ -123,42 +137,47 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
|||||||
|
|
||||||
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
||||||
|
|
||||||
erargs = parse_arguments(['--multi', str(playercount)])
|
args = mystery_argparse([]) # Just to set up the Namespace with defaults
|
||||||
erargs.seed = seed
|
args.multi = playercount
|
||||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
args.seed = seed
|
||||||
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
|
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||||
erargs.race = race
|
args.spoiler = meta["generator_options"].get("spoiler", 0)
|
||||||
erargs.outputname = seedname
|
args.race = race
|
||||||
erargs.outputpath = target.name
|
args.outputname = seedname
|
||||||
erargs.teams = 1
|
args.outputpath = target.name
|
||||||
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
args.teams = 1
|
||||||
{"bosses", "items", "connections", "texts"}))
|
args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||||
erargs.skip_prog_balancing = False
|
{"bosses", "items", "connections", "texts"}))
|
||||||
erargs.skip_output = False
|
args.skip_prog_balancing = False
|
||||||
erargs.csv_output = False
|
args.skip_output = False
|
||||||
|
args.spoiler_only = False
|
||||||
|
args.csv_output = False
|
||||||
|
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
|
||||||
|
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
||||||
|
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||||
for k, v in settings.items():
|
for k, v in settings.items():
|
||||||
if v is not None:
|
if v is not None:
|
||||||
if hasattr(erargs, k):
|
if hasattr(args, k):
|
||||||
getattr(erargs, k)[player] = v
|
getattr(args, k)[player] = v
|
||||||
else:
|
else:
|
||||||
setattr(erargs, k, {player: v})
|
setattr(args, k, {player: v})
|
||||||
|
|
||||||
if not erargs.name[player]:
|
if not args.name[player]:
|
||||||
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
||||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
args.name[player] = handle_name(args.name[player], player, name_counter)
|
||||||
if len(set(erargs.name.values())) != len(erargs.name):
|
if len(set(args.name.values())) != len(args.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}")
|
||||||
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
ERmain(args, seed, baked_server_options=meta["server_options"])
|
||||||
|
|
||||||
return upload_to_db(target.name, sid, owner, race)
|
return upload_to_db(target.name, sid, owner, race)
|
||||||
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
||||||
|
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
|
||||||
thread = thread_pool.submit(task)
|
thread = thread_pool.submit(task)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return thread.result(app.config["JOB_TIME"])
|
return thread.result(timeout)
|
||||||
except concurrent.futures.TimeoutError as e:
|
except concurrent.futures.TimeoutError as e:
|
||||||
if sid:
|
if sid:
|
||||||
with db_session:
|
with db_session:
|
||||||
@@ -166,11 +185,14 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
|||||||
if gen is not None:
|
if gen is not None:
|
||||||
gen.state = STATE_ERROR
|
gen.state = STATE_ERROR
|
||||||
meta = json.loads(gen.meta)
|
meta = json.loads(gen.meta)
|
||||||
meta["error"] = (
|
meta["error"] = ("Allowed time for Generation exceeded, " +
|
||||||
"Allowed time for Generation exceeded, please consider generating locally instead. " +
|
"please consider generating locally instead. " +
|
||||||
e.__class__.__name__ + ": " + str(e))
|
format_exception(e))
|
||||||
gen.meta = json.dumps(meta)
|
gen.meta = json.dumps(meta)
|
||||||
commit()
|
commit()
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
# don't update db, retry next time
|
||||||
|
raise
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
if sid:
|
if sid:
|
||||||
with db_session:
|
with db_session:
|
||||||
@@ -178,10 +200,15 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
|||||||
if gen is not None:
|
if gen is not None:
|
||||||
gen.state = STATE_ERROR
|
gen.state = STATE_ERROR
|
||||||
meta = json.loads(gen.meta)
|
meta = json.loads(gen.meta)
|
||||||
meta["error"] = (e.__class__.__name__ + ": " + str(e))
|
meta["error"] = format_exception(e)
|
||||||
gen.meta = json.dumps(meta)
|
gen.meta = json.dumps(meta)
|
||||||
commit()
|
commit()
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
# free resources claimed by thread pool, if possible
|
||||||
|
# NOTE: Timeout depends on the process being killed at some point
|
||||||
|
# since we can't actually cancel a running gen at the moment.
|
||||||
|
thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/wait/<suuid:seed>')
|
@app.route('/wait/<suuid:seed>')
|
||||||
@@ -195,7 +222,9 @@ def wait_seed(seed: UUID):
|
|||||||
if not generation:
|
if not generation:
|
||||||
return "Generation not found."
|
return "Generation not found."
|
||||||
elif generation.state == STATE_ERROR:
|
elif generation.state == STATE_ERROR:
|
||||||
return render_template("seedError.html", seed_error=generation.meta)
|
meta = json.loads(generation.meta)
|
||||||
|
details = json.dumps(meta, indent=4).strip()
|
||||||
|
return render_template("seedError.html", seed_error=meta["error"], details=details)
|
||||||
return render_template("waitSeed.html", seed_id=seed_id)
|
return render_template("waitSeed.html", seed_id=seed_id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import threading
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from Utils import local_path, user_path
|
from Utils import local_path, user_path
|
||||||
from worlds.alttp.Rom import Sprite
|
|
||||||
|
|
||||||
|
|
||||||
def update_sprites_lttp():
|
def update_sprites_lttp():
|
||||||
|
from worlds.alttp.Rom import Sprite
|
||||||
from tkinter import Tk
|
from tkinter import Tk
|
||||||
from LttPAdjuster import get_image_for_sprite
|
from LttPAdjuster import get_image_for_sprite
|
||||||
from LttPAdjuster import BackgroundTaskProgress
|
from LttPAdjuster import BackgroundTaskProgress
|
||||||
@@ -14,7 +14,7 @@ def update_sprites_lttp():
|
|||||||
from LttPAdjuster import update_sprites
|
from LttPAdjuster import update_sprites
|
||||||
|
|
||||||
# Target directories
|
# Target directories
|
||||||
input_dir = user_path("data", "sprites", "alttpr")
|
input_dir = user_path("data", "sprites", "alttp", "remote")
|
||||||
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
||||||
|
|
||||||
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
||||||
|
|||||||
90
WebHostLib/markdown.py
Normal file
90
WebHostLib/markdown.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
import mistune
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ImgUrlRewriteInlineParser",
|
||||||
|
'render_markdown',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ImgUrlRewriteInlineParser(mistune.InlineParser):
|
||||||
|
relative_url_base: str
|
||||||
|
|
||||||
|
def __init__(self, relative_url_base: str, hard_wrap: bool = False) -> None:
|
||||||
|
super().__init__(hard_wrap)
|
||||||
|
self.relative_url_base = relative_url_base
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_game_name_by_folder_name(name: str) -> str | None:
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
|
||||||
|
for world_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
|
if world_type.__module__ == f"worlds.{name}":
|
||||||
|
return world_name
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_link(self, m: re.Match[str], state: mistune.InlineState) -> int | None:
|
||||||
|
res = super().parse_link(m, state)
|
||||||
|
if res is not None and state.tokens and state.tokens[-1]["type"] == "image":
|
||||||
|
image_token = state.tokens[-1]
|
||||||
|
url: str = image_token["attrs"]["url"]
|
||||||
|
if not url.startswith("/") and not "://" in url:
|
||||||
|
# replace relative URL to another world's doc folder with the webhost folder layout
|
||||||
|
if url.startswith("../../") and "/docs/" in self.relative_url_base:
|
||||||
|
parts = url.split("/", 4)
|
||||||
|
if parts[2] != ".." and parts[3] == "docs":
|
||||||
|
game_name = self._find_game_name_by_folder_name(parts[2])
|
||||||
|
if game_name is not None:
|
||||||
|
url = "/".join(parts[1:2] + [secure_filename(game_name)] + parts[4:])
|
||||||
|
# change relative URL to point to deployment folder
|
||||||
|
url = f"{self.relative_url_base}/{url}"
|
||||||
|
image_token['attrs']['url'] = url
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown(path: str, img_url_base: str | None = None) -> str:
|
||||||
|
markdown = mistune.create_markdown(
|
||||||
|
escape=False,
|
||||||
|
plugins=[
|
||||||
|
"strikethrough",
|
||||||
|
"footnotes",
|
||||||
|
"table",
|
||||||
|
"speedup",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
heading_id_count: Counter[str] = Counter()
|
||||||
|
|
||||||
|
def heading_id(text: str) -> str:
|
||||||
|
nonlocal heading_id_count
|
||||||
|
|
||||||
|
# there is no good way to do this without regex
|
||||||
|
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
|
||||||
|
n = heading_id_count[s]
|
||||||
|
heading_id_count[s] += 1
|
||||||
|
if n > 0:
|
||||||
|
s += f"-{n}"
|
||||||
|
return s
|
||||||
|
|
||||||
|
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
|
||||||
|
for tok in state.tokens:
|
||||||
|
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
|
||||||
|
text = tok["text"]
|
||||||
|
assert isinstance(text, str)
|
||||||
|
unique_id = heading_id(text)
|
||||||
|
tok["attrs"]["id"] = unique_id
|
||||||
|
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
|
||||||
|
|
||||||
|
markdown.before_render_hooks.append(id_hook)
|
||||||
|
if img_url_base:
|
||||||
|
markdown.inline = ImgUrlRewriteInlineParser(img_url_base)
|
||||||
|
|
||||||
|
with open(path, encoding="utf-8-sig") as f:
|
||||||
|
document = f.read()
|
||||||
|
html = markdown(document)
|
||||||
|
assert isinstance(html, str), "Unexpected mistune renderer in render_markdown"
|
||||||
|
return html
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
|
import warnings
|
||||||
|
from enum import StrEnum
|
||||||
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
|
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
|
||||||
|
|
||||||
import jinja2.exceptions
|
import jinja2.exceptions
|
||||||
@@ -7,22 +9,39 @@ from flask import request, redirect, url_for, render_template, Response, session
|
|||||||
from pony.orm import count, commit, db_session
|
from pony.orm import count, commit, db_session
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister, World
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
|
from .markdown import render_markdown
|
||||||
from .models import Seed, Room, Command, UUID, uuid4
|
from .models import Seed, Room, Command, UUID, uuid4
|
||||||
|
from Utils import title_sorted
|
||||||
|
|
||||||
|
class WebWorldTheme(StrEnum):
|
||||||
|
DIRT = "dirt"
|
||||||
|
GRASS = "grass"
|
||||||
|
GRASS_FLOWERS = "grassFlowers"
|
||||||
|
ICE = "ice"
|
||||||
|
JUNGLE = "jungle"
|
||||||
|
OCEAN = "ocean"
|
||||||
|
PARTY_TIME = "partyTime"
|
||||||
|
STONE = "stone"
|
||||||
|
|
||||||
|
def get_world_theme(game_name: str) -> str:
|
||||||
|
if game_name not in AutoWorldRegister.world_types:
|
||||||
|
return "grass"
|
||||||
|
chosen_theme = AutoWorldRegister.world_types[game_name].web.theme
|
||||||
|
available_themes = [theme.value for theme in WebWorldTheme]
|
||||||
|
if chosen_theme not in available_themes:
|
||||||
|
warnings.warn(f"Theme '{chosen_theme}' for {game_name} not valid, switching to default 'grass' theme.")
|
||||||
|
return "grass"
|
||||||
|
return chosen_theme
|
||||||
|
|
||||||
|
|
||||||
def get_world_theme(game_name: str):
|
def get_visible_worlds() -> dict[str, type(World)]:
|
||||||
if game_name in AutoWorldRegister.world_types:
|
worlds = {}
|
||||||
return AutoWorldRegister.world_types[game_name].web.theme
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
return 'grass'
|
if not world.hidden:
|
||||||
|
worlds[game] = world
|
||||||
|
return worlds
|
||||||
@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(404)
|
||||||
@@ -38,71 +57,101 @@ def start_playing():
|
|||||||
return render_template(f"startPlaying.html")
|
return render_template(f"startPlaying.html")
|
||||||
|
|
||||||
|
|
||||||
# Game Info Pages
|
|
||||||
@app.route('/games/<string:game>/info/<string:lang>')
|
@app.route('/games/<string:game>/info/<string:lang>')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def game_info(game, lang):
|
def game_info(game, lang):
|
||||||
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
"""Game Info Pages"""
|
||||||
|
try:
|
||||||
|
theme = get_world_theme(game)
|
||||||
|
secure_game_name = secure_filename(game)
|
||||||
|
lang = secure_filename(lang)
|
||||||
|
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
|
||||||
|
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
|
||||||
|
document = render_markdown(os.path.join(file_dir, f"{lang}_{secure_game_name}.md"), file_dir_url)
|
||||||
|
return render_template(
|
||||||
|
"markdown_document.html",
|
||||||
|
title=f"{game} Guide",
|
||||||
|
html_from_markdown=document,
|
||||||
|
theme=theme,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
# List of supported games
|
|
||||||
@app.route('/games')
|
@app.route('/games')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def games():
|
def games():
|
||||||
worlds = {}
|
"""List of supported games"""
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
return render_template("supportedGames.html", worlds=get_visible_worlds())
|
||||||
if not world.hidden:
|
|
||||||
worlds[game] = world
|
|
||||||
return render_template("supportedGames.html", worlds=worlds)
|
@app.route('/tutorial/<string:game>/<string:file>')
|
||||||
|
@cache.cached()
|
||||||
|
def tutorial(game: str, file: str):
|
||||||
|
try:
|
||||||
|
theme = get_world_theme(game)
|
||||||
|
secure_game_name = secure_filename(game)
|
||||||
|
file = secure_filename(file)
|
||||||
|
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
|
||||||
|
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
|
||||||
|
document = render_markdown(os.path.join(file_dir, f"{file}.md"), file_dir_url)
|
||||||
|
return render_template(
|
||||||
|
"markdown_document.html",
|
||||||
|
title=f"{game} Guide",
|
||||||
|
html_from_markdown=document,
|
||||||
|
theme=theme,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||||
@cache.cached()
|
def tutorial_redirect(game: str, file: str, lang: str):
|
||||||
def tutorial(game, file, lang):
|
"""
|
||||||
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
Permanent redirect old tutorial URLs to new ones to keep search engines happy.
|
||||||
|
e.g. /tutorial/Archipelago/setup/en -> /tutorial/Archipelago/setup_en
|
||||||
|
"""
|
||||||
|
return redirect(url_for("tutorial", game=game, file=f"{file}_{lang}"), code=301)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tutorial/')
|
@app.route('/tutorial/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def tutorial_landing():
|
def tutorial_landing():
|
||||||
return render_template("tutorialLanding.html")
|
tutorials = {}
|
||||||
|
worlds = AutoWorldRegister.world_types
|
||||||
|
for world_name, world_type in worlds.items():
|
||||||
|
current_world = tutorials[world_name] = {}
|
||||||
|
for tutorial in world_type.web.tutorials:
|
||||||
|
current_tutorial = current_world.setdefault(tutorial.tutorial_name, {
|
||||||
|
"description": tutorial.description, "files": {}})
|
||||||
|
current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = {
|
||||||
|
"authors": tutorial.authors,
|
||||||
|
"language": tutorial.language
|
||||||
|
}
|
||||||
|
tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
|
||||||
|
tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)}
|
||||||
|
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/faq/<string:lang>/')
|
@app.route('/faq/<string:lang>/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def faq(lang: str):
|
def faq(lang: str):
|
||||||
import markdown
|
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md"))
|
||||||
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
|
|
||||||
document = f.read()
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"markdown_document.html",
|
"markdown_document.html",
|
||||||
title="Frequently Asked Questions",
|
title="Frequently Asked Questions",
|
||||||
html_from_markdown=markdown.markdown(
|
html_from_markdown=document,
|
||||||
document,
|
|
||||||
extensions=["toc", "mdx_breakless_lists"],
|
|
||||||
extension_configs={
|
|
||||||
"toc": {"anchorlink": True}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/glossary/<string:lang>/')
|
@app.route('/glossary/<string:lang>/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def glossary(lang: str):
|
def glossary(lang: str):
|
||||||
import markdown
|
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md"))
|
||||||
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
|
|
||||||
document = f.read()
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"markdown_document.html",
|
"markdown_document.html",
|
||||||
title="Glossary",
|
title="Glossary",
|
||||||
html_from_markdown=markdown.markdown(
|
html_from_markdown=document,
|
||||||
document,
|
|
||||||
extensions=["toc", "mdx_breakless_lists"],
|
|
||||||
extension_configs={
|
|
||||||
"toc": {"anchorlink": True}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -183,7 +232,10 @@ def host_room(room: UUID):
|
|||||||
# indicate that the page should reload to get the assigned port
|
# 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))
|
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
||||||
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
||||||
with db_session:
|
|
||||||
|
if now - room.last_activity > datetime.timedelta(minutes=1):
|
||||||
|
# we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error
|
||||||
|
# due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"
|
||||||
room.last_activity = now # will trigger a spinup, if it's not already running
|
room.last_activity = now # will trigger a spinup, if it's not already running
|
||||||
|
|
||||||
browser_tokens = "Mozilla", "Chrome", "Safari"
|
browser_tokens = "Mozilla", "Chrome", "Safari"
|
||||||
@@ -191,9 +243,9 @@ def host_room(room: UUID):
|
|||||||
or "Discordbot" in request.user_agent.string
|
or "Discordbot" in request.user_agent.string
|
||||||
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
|
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
|
||||||
|
|
||||||
def get_log(max_size: int = 0 if automated else 1024000) -> str:
|
def get_log(max_size: int = 0 if automated else 1024000) -> Tuple[str, int]:
|
||||||
if max_size == 0:
|
if max_size == 0:
|
||||||
return "…"
|
return "…", 0
|
||||||
try:
|
try:
|
||||||
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
||||||
raw_size = 0
|
raw_size = 0
|
||||||
@@ -204,9 +256,9 @@ def host_room(room: UUID):
|
|||||||
break
|
break
|
||||||
raw_size += len(block)
|
raw_size += len(block)
|
||||||
fragments.append(block.decode("utf-8"))
|
fragments.append(block.decode("utf-8"))
|
||||||
return "".join(fragments)
|
return "".join(fragments), raw_size
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return ""
|
return "", 0
|
||||||
|
|
||||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
|
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ from typing import Dict, Union
|
|||||||
from docutils.core import publish_parts
|
from docutils.core import publish_parts
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from flask import redirect, render_template, request, Response
|
from flask import redirect, render_template, request, Response, abort
|
||||||
|
|
||||||
import Options
|
import Options
|
||||||
from Utils import local_path
|
from Utils import local_path
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
from .generate import get_meta
|
from .generate import get_meta
|
||||||
|
from .misc import get_world_theme
|
||||||
|
|
||||||
|
|
||||||
def create() -> None:
|
def create() -> None:
|
||||||
@@ -22,12 +23,6 @@ def create() -> None:
|
|||||||
Options.generate_yaml_templates(yaml_folder)
|
Options.generate_yaml_templates(yaml_folder)
|
||||||
|
|
||||||
|
|
||||||
def get_world_theme(game_name: str) -> str:
|
|
||||||
if game_name in AutoWorldRegister.world_types:
|
|
||||||
return AutoWorldRegister.world_types[game_name].web.theme
|
|
||||||
return 'grass'
|
|
||||||
|
|
||||||
|
|
||||||
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
|
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
|
||||||
world = AutoWorldRegister.world_types[world_name]
|
world = AutoWorldRegister.world_types[world_name]
|
||||||
if world.hidden or world.web.options_page is False:
|
if world.hidden or world.web.options_page is False:
|
||||||
@@ -76,7 +71,7 @@ def filter_rst_to_html(text: str) -> str:
|
|||||||
lines = text.splitlines()
|
lines = text.splitlines()
|
||||||
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
||||||
|
|
||||||
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
|
return publish_parts(text, writer='html', settings=None, settings_overrides={
|
||||||
'raw_enable': False,
|
'raw_enable': False,
|
||||||
'file_insertion_enabled': False,
|
'file_insertion_enabled': False,
|
||||||
'output_encoding': 'unicode'
|
'output_encoding': 'unicode'
|
||||||
@@ -108,7 +103,7 @@ def option_presets(game: str) -> Response:
|
|||||||
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
||||||
|
|
||||||
presets[preset_name][preset_option_name] = option.value
|
presets[preset_name][preset_option_name] = option.value
|
||||||
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
|
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.OptionCounter)):
|
||||||
presets[preset_name][preset_option_name] = option.value
|
presets[preset_name][preset_option_name] = option.value
|
||||||
elif isinstance(preset_option, str):
|
elif isinstance(preset_option, str):
|
||||||
# Ensure the option value is valid for Choice and Toggle options
|
# Ensure the option value is valid for Choice and Toggle options
|
||||||
@@ -142,7 +137,10 @@ def weighted_options_old():
|
|||||||
@app.route("/games/<string:game>/weighted-options")
|
@app.route("/games/<string:game>/weighted-options")
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def weighted_options(game: str):
|
def weighted_options(game: str):
|
||||||
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
try:
|
||||||
|
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
|
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
|
||||||
@@ -152,7 +150,9 @@ def generate_weighted_yaml(game: str):
|
|||||||
options = {}
|
options = {}
|
||||||
|
|
||||||
for key, val in request.form.items():
|
for key, val in request.form.items():
|
||||||
if "||" not in key:
|
if val == "_ensure-empty-list":
|
||||||
|
options[key] = {}
|
||||||
|
elif "||" not in key:
|
||||||
if len(str(val)) == 0:
|
if len(str(val)) == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -197,7 +197,10 @@ def generate_weighted_yaml(game: str):
|
|||||||
@app.route("/games/<string:game>/player-options")
|
@app.route("/games/<string:game>/player-options")
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def player_options(game: str):
|
def player_options(game: str):
|
||||||
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
try:
|
||||||
|
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
# YAML generator for player-options
|
# YAML generator for player-options
|
||||||
@@ -206,8 +209,11 @@ def generate_yaml(game: str):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
options = {}
|
options = {}
|
||||||
intent_generate = False
|
intent_generate = False
|
||||||
|
|
||||||
for key, val in request.form.items(multi=True):
|
for key, val in request.form.items(multi=True):
|
||||||
if key in options:
|
if val == "_ensure-empty-list":
|
||||||
|
options[key] = []
|
||||||
|
elif options.get(key):
|
||||||
if not isinstance(options[key], list):
|
if not isinstance(options[key], list):
|
||||||
options[key] = [options[key]]
|
options[key] = [options[key]]
|
||||||
options[key].append(val)
|
options[key].append(val)
|
||||||
@@ -216,11 +222,11 @@ def generate_yaml(game: str):
|
|||||||
|
|
||||||
for key, val in options.copy().items():
|
for key, val in options.copy().items():
|
||||||
key_parts = key.rsplit("||", 2)
|
key_parts = key.rsplit("||", 2)
|
||||||
# Detect and build ItemDict options from their name pattern
|
# Detect and build OptionCounter options from their name pattern
|
||||||
if key_parts[-1] == "qty":
|
if key_parts[-1] == "qty":
|
||||||
if key_parts[0] not in options:
|
if key_parts[0] not in options:
|
||||||
options[key_parts[0]] = {}
|
options[key_parts[0]] = {}
|
||||||
if val != "0":
|
if val and val != "0":
|
||||||
options[key_parts[0]][key_parts[1]] = int(val)
|
options[key_parts[0]][key_parts[1]] = int(val)
|
||||||
del options[key]
|
del options[key]
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
flask>=3.0.3
|
flask>=3.1.1
|
||||||
werkzeug>=3.0.6
|
werkzeug>=3.1.3
|
||||||
pony>=0.7.19
|
pony>=0.7.19; python_version <= '3.12'
|
||||||
waitress>=3.0.0
|
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
|
||||||
|
waitress>=3.0.2
|
||||||
Flask-Caching>=2.3.0
|
Flask-Caching>=2.3.0
|
||||||
Flask-Compress>=1.15
|
Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
|
||||||
Flask-Limiter>=3.8.0
|
Flask-Limiter>=3.12
|
||||||
bokeh>=3.5.2
|
bokeh>=3.6.3
|
||||||
markupsafe>=2.1.5
|
markupsafe>=3.0.2
|
||||||
Markdown>=3.7
|
setproctitle>=1.3.5
|
||||||
mdx-breakless-lists>=1.0.1
|
mistune>=3.1.3
|
||||||
|
docutils>=0.22.2
|
||||||
|
|||||||
31
WebHostLib/session.py
Normal file
31
WebHostLib/session.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from uuid import uuid4, UUID
|
||||||
|
|
||||||
|
from flask import session, render_template
|
||||||
|
|
||||||
|
from WebHostLib import app
|
||||||
|
|
||||||
|
|
||||||
|
@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.route('/session')
|
||||||
|
def show_session():
|
||||||
|
return render_template(
|
||||||
|
"session.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/session/<string:_id>')
|
||||||
|
def set_session(_id: str):
|
||||||
|
new_id: UUID = UUID(_id, version=4)
|
||||||
|
old_id: UUID = session["_id"]
|
||||||
|
if old_id != new_id:
|
||||||
|
session["_id"] = new_id
|
||||||
|
return render_template(
|
||||||
|
"session.html",
|
||||||
|
old_id=old_id,
|
||||||
|
)
|
||||||
@@ -22,8 +22,8 @@ players to rely upon each other to complete their game.
|
|||||||
|
|
||||||
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
||||||
players to randomize any of the supported games, and send items between them. This allows players of different
|
players to randomize any of the supported games, and send items between them. This allows players of different
|
||||||
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
|
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
|
||||||
Here is a list of our [Supported Games](https://archipelago.gg/games).
|
Here is a list of our [Supported Games](/games).
|
||||||
|
|
||||||
## Can I generate a single-player game with Archipelago?
|
## Can I generate a single-player game with Archipelago?
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ play, open the Settings Page, pick your settings, and click Generate Game.
|
|||||||
|
|
||||||
## How do I get started?
|
## How do I get started?
|
||||||
|
|
||||||
We have a [Getting Started](https://archipelago.gg/tutorial/Archipelago/setup/en) guide that will help you get the
|
We have a [Getting Started](/tutorial/Archipelago/setup/en) guide that will help you get the
|
||||||
software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
|
software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
|
||||||
including multiple games, and hosting multiworlds on the website for ease and convenience.
|
including multiple games, and hosting multiworlds on the website for ease and convenience.
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ their multiworld.
|
|||||||
|
|
||||||
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
|
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
|
||||||
in that game belonging to other players are sent out automatically. This allows other players to continue to play
|
in that game belonging to other players are sent out automatically. This allows other players to continue to play
|
||||||
uninterrupted. Here is a list of all of our [Server Commands](https://archipelago.gg/tutorial/Archipelago/commands/en).
|
uninterrupted. Here is a list of all of our [Server Commands](/tutorial/Archipelago/commands/en).
|
||||||
|
|
||||||
## What happens if an item is placed somewhere it is impossible to get?
|
## What happens if an item is placed somewhere it is impossible to get?
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ is to ensure items necessary to complete the game will be accessible to the play
|
|||||||
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
|
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
|
||||||
comfortable exploiting certain glitches in the game.
|
comfortable exploiting certain glitches in the game.
|
||||||
|
|
||||||
## I want to add a game to the Archipelago randomizer. How do I do that?
|
## I want to develop a game implementation for Archipelago. How do I do that?
|
||||||
|
|
||||||
The best way to get started is to take a look at our code on GitHub:
|
The best way to get started is to take a look at our code on GitHub:
|
||||||
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
|
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
|
||||||
@@ -77,4 +77,5 @@ There, you will find examples of games in the `worlds` folder:
|
|||||||
You may also find developer documentation in the `docs` folder:
|
You may also find developer documentation in the `docs` folder:
|
||||||
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
|
||||||
|
|
||||||
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.
|
If you have more questions regarding development of a game implementation, feel free to ask in the **#ap-world-dev**
|
||||||
|
channel on our Discord.
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
const gameInfo = document.getElementById('game-info');
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
if (ajax.status === 404) {
|
|
||||||
reject("Sorry, this game's info page is not available in that language yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ajax.status !== 200) {
|
|
||||||
reject("Something went wrong while loading the info page.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(ajax.responseText);
|
|
||||||
};
|
|
||||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
|
|
||||||
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
|
|
||||||
ajax.send();
|
|
||||||
}).then((results) => {
|
|
||||||
// Populate page with HTML generated from markdown
|
|
||||||
showdown.setOption('tables', true);
|
|
||||||
showdown.setOption('strikethrough', true);
|
|
||||||
showdown.setOption('literalMidWordUnderscores', true);
|
|
||||||
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
|
|
||||||
adjustHeaderWidth();
|
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
|
||||||
header.setAttribute('id', headerId);
|
|
||||||
header.addEventListener('click', () => {
|
|
||||||
window.location.hash = `#${headerId}`;
|
|
||||||
header.scrollIntoView();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
|
||||||
document.fonts.ready.finally(() => {
|
|
||||||
if (window.location.hash) {
|
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
|
||||||
scrollTarget?.scrollIntoView();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
gameInfo.innerHTML =
|
|
||||||
`<h2>This page is out of logic!</h2>
|
|
||||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,6 +6,4 @@ window.addEventListener('load', () => {
|
|||||||
document.getElementById('file-input').addEventListener('change', () => {
|
document.getElementById('file-input').addEventListener('change', () => {
|
||||||
document.getElementById('host-game-form').submit();
|
document.getElementById('host-game-form').submit();
|
||||||
});
|
});
|
||||||
|
|
||||||
adjustFooterHeight();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
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,49 +1,43 @@
|
|||||||
|
let updateSection = (sectionName, fakeDOM) => {
|
||||||
|
document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
// Reload tracker every 15 seconds
|
// Reload tracker every 60 seconds (sync'd)
|
||||||
const url = window.location;
|
const url = window.location;
|
||||||
setInterval(() => {
|
// Note: This synchronization code is adapted from code in trackerCommon.js
|
||||||
const ajax = new XMLHttpRequest();
|
const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3;
|
||||||
ajax.onreadystatechange = () => {
|
console.log("Target second of refresh: " + targetSecond);
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
|
|
||||||
// Create a fake DOM using the returned HTML
|
let getSleepTimeSeconds = () => {
|
||||||
const domParser = new DOMParser();
|
// -40 % 60 is -40, which is absolutely wrong and should burn
|
||||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60;
|
||||||
|
return sleepSeconds || 60;
|
||||||
// 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
|
let updateTracker = () => {
|
||||||
const categories = document.getElementsByClassName("location-category");
|
const ajax = new XMLHttpRequest();
|
||||||
for (let category of categories) {
|
ajax.onreadystatechange = () => {
|
||||||
let hide_id = category.id.split('_')[0];
|
if (ajax.readyState !== 4) { return; }
|
||||||
if (hide_id === 'Total') {
|
|
||||||
continue;
|
// Create a fake DOM using the returned HTML
|
||||||
}
|
const domParser = new DOMParser();
|
||||||
category.addEventListener('click', function() {
|
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||||
// Toggle the advancement list
|
|
||||||
document.getElementById(hide_id).classList.toggle("hide");
|
// Update dynamic sections
|
||||||
// Change text of the header
|
updateSection('player-info', fakeDOM);
|
||||||
const tab_header = document.getElementById(hide_id+'_header').children[0];
|
updateSection('section-filler', fakeDOM);
|
||||||
const orig_text = tab_header.innerHTML;
|
updateSection('section-terran', fakeDOM);
|
||||||
let new_text;
|
updateSection('section-zerg', fakeDOM);
|
||||||
if (orig_text.includes("▼")) {
|
updateSection('section-protoss', fakeDOM);
|
||||||
new_text = orig_text.replace("▼", "▲");
|
updateSection('section-nova', fakeDOM);
|
||||||
}
|
updateSection('section-kerrigan', fakeDOM);
|
||||||
else {
|
updateSection('section-keys', fakeDOM);
|
||||||
new_text = orig_text.replace("▲", "▼");
|
updateSection('section-locations', fakeDOM);
|
||||||
}
|
};
|
||||||
tab_header.innerHTML = new_text;
|
ajax.open('GET', url);
|
||||||
});
|
ajax.send();
|
||||||
}
|
updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
||||||
|
};
|
||||||
|
window.updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
const adjustFooterHeight = () => {
|
|
||||||
// If there is no footer on this page, do nothing
|
|
||||||
const footer = document.getElementById('island-footer');
|
|
||||||
if (!footer) { return; }
|
|
||||||
|
|
||||||
// If the body is taller than the window, also do nothing
|
|
||||||
if (document.body.offsetHeight > window.innerHeight) {
|
|
||||||
footer.style.marginTop = '0';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a margin-top to the footer to position it at the bottom of the screen
|
|
||||||
const sibling = footer.previousElementSibling;
|
|
||||||
const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight);
|
|
||||||
if (margin < 1) {
|
|
||||||
footer.style.marginTop = '0';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
footer.style.marginTop = `${margin}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const adjustHeaderWidth = () => {
|
|
||||||
// If there is no header, do nothing
|
|
||||||
const header = document.getElementById('base-header');
|
|
||||||
if (!header) { return; }
|
|
||||||
|
|
||||||
const tempDiv = document.createElement('div');
|
|
||||||
tempDiv.style.width = '100px';
|
|
||||||
tempDiv.style.height = '100px';
|
|
||||||
tempDiv.style.overflow = 'scroll';
|
|
||||||
tempDiv.style.position = 'absolute';
|
|
||||||
tempDiv.style.top = '-500px';
|
|
||||||
document.body.appendChild(tempDiv);
|
|
||||||
const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth;
|
|
||||||
document.body.removeChild(tempDiv);
|
|
||||||
|
|
||||||
const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement;
|
|
||||||
const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0;
|
|
||||||
document.getElementById('base-header-right').style.marginRight = `${margin}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
window.addEventListener('resize', adjustFooterHeight);
|
|
||||||
window.addEventListener('resize', adjustHeaderWidth);
|
|
||||||
adjustFooterHeight();
|
|
||||||
adjustHeaderWidth();
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
const tutorialWrapper = document.getElementById('tutorial-wrapper');
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
if (ajax.status === 404) {
|
|
||||||
reject("Sorry, the tutorial is not available in that language yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ajax.status !== 200) {
|
|
||||||
reject("Something went wrong while loading the tutorial.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(ajax.responseText);
|
|
||||||
};
|
|
||||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
|
|
||||||
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
|
|
||||||
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
|
||||||
ajax.send();
|
|
||||||
}).then((results) => {
|
|
||||||
// Populate page with HTML generated from markdown
|
|
||||||
showdown.setOption('tables', true);
|
|
||||||
showdown.setOption('strikethrough', true);
|
|
||||||
showdown.setOption('literalMidWordUnderscores', true);
|
|
||||||
showdown.setOption('disableForced4SpacesIndentedSublists', true);
|
|
||||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
|
||||||
adjustHeaderWidth();
|
|
||||||
|
|
||||||
const title = document.querySelector('h1')
|
|
||||||
if (title) {
|
|
||||||
document.title = title.textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
|
||||||
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}/tutorial">here</a> to return to safety.</h3>`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
const showError = () => {
|
|
||||||
const tutorial = document.getElementById('tutorial-landing');
|
|
||||||
document.getElementById('page-title').innerText = 'This page is out of logic!';
|
|
||||||
tutorial.removeChild(document.getElementById('loading'));
|
|
||||||
const userMessage = document.createElement('h3');
|
|
||||||
const homepageLink = document.createElement('a');
|
|
||||||
homepageLink.innerText = 'Click here';
|
|
||||||
homepageLink.setAttribute('href', '/');
|
|
||||||
userMessage.append(homepageLink);
|
|
||||||
userMessage.append(' to go back to safety!');
|
|
||||||
tutorial.append(userMessage);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
const tutorialDiv = document.getElementById('tutorial-landing');
|
|
||||||
if (ajax.status !== 200) { return showError(); }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const games = JSON.parse(ajax.responseText);
|
|
||||||
games.forEach((game) => {
|
|
||||||
const gameTitle = document.createElement('h2');
|
|
||||||
gameTitle.innerText = game.gameTitle;
|
|
||||||
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
|
|
||||||
tutorialDiv.appendChild(gameTitle);
|
|
||||||
|
|
||||||
game.tutorials.forEach((tutorial) => {
|
|
||||||
const tutorialName = document.createElement('h3');
|
|
||||||
tutorialName.innerText = tutorial.name;
|
|
||||||
tutorialDiv.appendChild(tutorialName);
|
|
||||||
|
|
||||||
const tutorialDescription = document.createElement('p');
|
|
||||||
tutorialDescription.innerText = tutorial.description;
|
|
||||||
tutorialDiv.appendChild(tutorialDescription);
|
|
||||||
|
|
||||||
const intro = document.createElement('p');
|
|
||||||
intro.innerText = 'This guide is available in the following languages:';
|
|
||||||
tutorialDiv.appendChild(intro);
|
|
||||||
|
|
||||||
const fileList = document.createElement('ul');
|
|
||||||
tutorial.files.forEach((file) => {
|
|
||||||
const listItem = document.createElement('li');
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.innerText = file.language;
|
|
||||||
anchor.setAttribute('href', `${window.location.origin}/tutorial/${file.link}`);
|
|
||||||
listItem.appendChild(anchor);
|
|
||||||
|
|
||||||
listItem.append(' by ');
|
|
||||||
for (let author of file.authors) {
|
|
||||||
listItem.append(author);
|
|
||||||
if (file.authors.indexOf(author) !== (file.authors.length -1)) {
|
|
||||||
listItem.append(', ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileList.appendChild(listItem);
|
|
||||||
});
|
|
||||||
tutorialDiv.appendChild(fileList);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tutorialDiv.removeChild(document.getElementById('loading'));
|
|
||||||
} catch (error) {
|
|
||||||
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/generated/tutorials.json`, true);
|
|
||||||
ajax.send();
|
|
||||||
});
|
|
||||||
@@ -36,6 +36,13 @@ html{
|
|||||||
|
|
||||||
body{
|
body{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: calc(100vh - 110px);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
a{
|
a{
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-family: LondrinaSolid-Regular, sans-serif;
|
font-family: LondrinaSolid-Regular, sans-serif;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-shadow: 1px 1px 4px #000000;
|
text-shadow: 1px 1px 4px #000000;
|
||||||
}
|
}
|
||||||
@@ -37,7 +36,6 @@
|
|||||||
font-size: 38px;
|
font-size: 38px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-family: LondrinaSolid-Light, sans-serif;
|
font-family: LondrinaSolid-Light, sans-serif;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
@@ -50,7 +48,6 @@
|
|||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -59,7 +56,6 @@
|
|||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,14 +63,12 @@
|
|||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h6, .markdown details summary.h6{
|
.markdown h6, .markdown details summary.h6{
|
||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h4, .markdown h5, .markdown h6{
|
.markdown h4, .markdown h5, .markdown h6{
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
#player-tracker-wrapper{
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table{
|
|
||||||
border-top: 2px solid #000000;
|
|
||||||
border-left: 2px solid #000000;
|
|
||||||
border-right: 2px solid #000000;
|
|
||||||
border-top-left-radius: 4px;
|
|
||||||
border-top-right-radius: 4px;
|
|
||||||
padding: 3px 3px 10px;
|
|
||||||
width: 384px;
|
|
||||||
background-color: #42b149;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table td{
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
text-align: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table img{
|
|
||||||
height: 100%;
|
|
||||||
max-width: 40px;
|
|
||||||
max-height: 40px;
|
|
||||||
filter: grayscale(100%) contrast(75%) brightness(30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table img.acquired{
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table div.counted-item {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table div.item-count {
|
|
||||||
position: absolute;
|
|
||||||
color: white;
|
|
||||||
font-family: "Minecraftia", monospace;
|
|
||||||
font-weight: bold;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table{
|
|
||||||
width: 384px;
|
|
||||||
border-left: 2px solid #000000;
|
|
||||||
border-right: 2px solid #000000;
|
|
||||||
border-bottom: 2px solid #000000;
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
border-bottom-right-radius: 4px;
|
|
||||||
background-color: #42b149;
|
|
||||||
padding: 0 3px 3px;
|
|
||||||
font-family: "Minecraftia", monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table th{
|
|
||||||
vertical-align: middle;
|
|
||||||
text-align: left;
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td{
|
|
||||||
padding-top: 2px;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.counter {
|
|
||||||
text-align: right;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.toggle-arrow {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table tr#Total-header {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table img{
|
|
||||||
height: 100%;
|
|
||||||
max-width: 30px;
|
|
||||||
max-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table tbody.locations {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.location-name {
|
|
||||||
padding-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
@@ -1,160 +1,276 @@
|
|||||||
#player-tracker-wrapper{
|
*{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: "JuraBook", monospace;
|
||||||
|
}
|
||||||
|
body{
|
||||||
|
--icon-size: 36px;
|
||||||
|
--item-class-padding: 4px;
|
||||||
|
}
|
||||||
|
a{
|
||||||
|
color: #1ae;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tracker-table td {
|
/* Section colours */
|
||||||
vertical-align: top;
|
#player-info{
|
||||||
|
background-color: #37a;
|
||||||
|
}
|
||||||
|
.player-tracker{
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.tracker-section{
|
||||||
|
background-color: grey;
|
||||||
|
}
|
||||||
|
#terran-items{
|
||||||
|
background-color: #3a7;
|
||||||
|
}
|
||||||
|
#zerg-items{
|
||||||
|
background-color: #d94;
|
||||||
|
}
|
||||||
|
#protoss-items{
|
||||||
|
background-color: #37a;
|
||||||
|
}
|
||||||
|
#nova-items{
|
||||||
|
background-color: #777;
|
||||||
|
}
|
||||||
|
#kerrigan-items{
|
||||||
|
background-color: #a37;
|
||||||
|
}
|
||||||
|
#keys{
|
||||||
|
background-color: #aa2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table-area{
|
/* Sections */
|
||||||
border: 2px solid #000000;
|
.section-body{
|
||||||
border-radius: 4px;
|
display: flex;
|
||||||
padding: 3px 10px 3px 10px;
|
flex-flow: row wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
.section-body-2{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body,
|
||||||
|
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body-2{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.section-title{
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 3px solid black;
|
||||||
|
/* Prevent text selection */
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
input[type="checkbox"]{
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.section-title:hover h2{
|
||||||
|
text-shadow: 0 0 4px #ddd;
|
||||||
|
}
|
||||||
|
.f {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table-area:has(.inventory-table-terran) {
|
/* Acquire item filters */
|
||||||
width: 690px;
|
.tracker-section img{
|
||||||
background-color: #525494;
|
height: 100%;
|
||||||
|
width: var(--icon-size);
|
||||||
|
height: var(--icon-size);
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
.unacquired, .lvl-0 .f{
|
||||||
|
filter: grayscale(100%) contrast(80%) brightness(42%) blur(0.5px);
|
||||||
|
}
|
||||||
|
.spacer{
|
||||||
|
width: var(--icon-size);
|
||||||
|
height: var(--icon-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table-area:has(.inventory-table-zerg) {
|
/* Item groups */
|
||||||
width: 360px;
|
.item-class{
|
||||||
background-color: #9d60d2;
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--item-class-padding);
|
||||||
|
}
|
||||||
|
.item-class-header{
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row;
|
||||||
|
}
|
||||||
|
.item-class-upgrades{
|
||||||
|
/* Note: {display: flex; flex-flow: column wrap} */
|
||||||
|
/* just breaks on Firefox (width does not scale to content) */
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(4, auto);
|
||||||
|
grid-auto-flow: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table-area:has(.inventory-table-protoss) {
|
/* Subsections */
|
||||||
width: 400px;
|
.section-toc{
|
||||||
background-color: #d2b260;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.toc-box{
|
||||||
|
position: relative;
|
||||||
|
padding-left: 15px;
|
||||||
|
padding-right: 15px;
|
||||||
|
}
|
||||||
|
.toc-box:hover{
|
||||||
|
text-shadow: 0 0 7px white;
|
||||||
|
}
|
||||||
|
.ss-header{
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
writing-mode: sideways-lr;
|
||||||
|
user-select: none;
|
||||||
|
padding-top: 5px;
|
||||||
|
font-size: 115%;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-1-toggle:checked) .ss-1{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-2-toggle:checked) .ss-2{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-3-toggle:checked) .ss-3{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-4-toggle:checked) .ss-4{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-5-toggle:checked) .ss-5{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-6-toggle:checked) .ss-6{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-7-toggle:checked) .ss-7{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-1-toggle:hover) .ss-1{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-2-toggle:hover) .ss-2{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-3-toggle:hover) .ss-3{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-4-toggle:hover) .ss-4{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-5-toggle:hover) .ss-5{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-6-toggle:hover) .ss-6{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-7-toggle:hover) .ss-7{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tracker-table .inventory-table td{
|
/* Progressive items */
|
||||||
width: 40px;
|
.progressive{
|
||||||
height: 40px;
|
max-height: var(--icon-size);
|
||||||
text-align: center;
|
display: contents;
|
||||||
vertical-align: middle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table td.title{
|
.lvl-0 > :nth-child(2),
|
||||||
padding-top: 10px;
|
.lvl-0 > :nth-child(3),
|
||||||
height: 20px;
|
.lvl-0 > :nth-child(4),
|
||||||
font-family: "JuraBook", monospace;
|
.lvl-0 > :nth-child(5){
|
||||||
font-size: 16px;
|
display: none;
|
||||||
font-weight: bold;
|
}
|
||||||
|
.lvl-1 > :nth-child(2),
|
||||||
|
.lvl-1 > :nth-child(3),
|
||||||
|
.lvl-1 > :nth-child(4),
|
||||||
|
.lvl-1 > :nth-child(5){
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lvl-2 > :nth-child(1),
|
||||||
|
.lvl-2 > :nth-child(3),
|
||||||
|
.lvl-2 > :nth-child(4),
|
||||||
|
.lvl-2 > :nth-child(5){
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lvl-3 > :nth-child(1),
|
||||||
|
.lvl-3 > :nth-child(2),
|
||||||
|
.lvl-3 > :nth-child(4),
|
||||||
|
.lvl-3 > :nth-child(5){
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lvl-4 > :nth-child(1),
|
||||||
|
.lvl-4 > :nth-child(2),
|
||||||
|
.lvl-4 > :nth-child(3),
|
||||||
|
.lvl-4 > :nth-child(5){
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lvl-5 > :nth-child(1),
|
||||||
|
.lvl-5 > :nth-child(2),
|
||||||
|
.lvl-5 > :nth-child(3),
|
||||||
|
.lvl-5 > :nth-child(4){
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table img{
|
/* Filler item counters */
|
||||||
height: 100%;
|
.item-counter{
|
||||||
max-width: 40px;
|
display: table;
|
||||||
max-height: 40px;
|
text-align: center;
|
||||||
border: 1px solid #000000;
|
padding: var(--item-class-padding);
|
||||||
filter: grayscale(100%) contrast(75%) brightness(20%);
|
}
|
||||||
background-color: black;
|
.item-count{
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: middle;
|
||||||
|
padding-left: 3px;
|
||||||
|
padding-right: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table img.acquired{
|
/* Hidden items */
|
||||||
filter: none;
|
.hidden-class:not(:has(.f:not(.unacquired))), .hidden-item{
|
||||||
background-color: black;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table .tint-terran img.acquired {
|
/* Keys */
|
||||||
filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg)
|
#keys ol, #keys ul{
|
||||||
|
columns: 3;
|
||||||
|
-webkit-columns: 3;
|
||||||
|
-moz-columns: 3;
|
||||||
|
}
|
||||||
|
#keys li{
|
||||||
|
padding-right: 15pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table .tint-protoss img.acquired {
|
/* Locations */
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg)
|
#section-locations{
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
@media only screen and (min-width: 120ch){
|
||||||
|
#section-locations ul{
|
||||||
|
columns: 2;
|
||||||
|
-webkit-columns: 2;
|
||||||
|
-moz-columns: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#locations li.checked{
|
||||||
|
list-style-type: "✔ ";
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table .tint-level-1 img.acquired {
|
/* Allowing scrolling down a little further */
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg)
|
.bottom-padding{
|
||||||
}
|
min-height: 33vh;
|
||||||
|
}
|
||||||
.inventory-table .tint-level-2 img.acquired {
|
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(120deg)
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table .tint-level-3 img.acquired {
|
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(240deg)
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table div.counted-item {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table div.item-count {
|
|
||||||
width: 160px;
|
|
||||||
text-align: left;
|
|
||||||
color: black;
|
|
||||||
font-family: "JuraBook", monospace;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table{
|
|
||||||
border: 2px solid #000000;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #87b678;
|
|
||||||
padding: 10px 3px 3px;
|
|
||||||
font-family: "JuraBook", monospace;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table table{
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table th{
|
|
||||||
vertical-align: middle;
|
|
||||||
text-align: left;
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td{
|
|
||||||
padding-top: 2px;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.counter {
|
|
||||||
text-align: right;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.toggle-arrow {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table tr#Total-header {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table img{
|
|
||||||
height: 100%;
|
|
||||||
max-width: 30px;
|
|
||||||
max-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table tbody.locations {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.location-name {
|
|
||||||
padding-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td:has(.location-column) {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table .location-column {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table .location-column .spacer {
|
|
||||||
min-height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
3965
WebHostLib/static/styles/sc2TrackerAtlas.css
Normal file
3965
WebHostLib/static/styles/sc2TrackerAtlas.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -72,3 +72,13 @@ code{
|
|||||||
padding-right: 0.25rem;
|
padding-right: 0.25rem;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code.grassy {
|
||||||
|
background-color: #b5e9a4;
|
||||||
|
border: 1px solid #2a6c2f;
|
||||||
|
white-space: preserve;
|
||||||
|
text-align: left;
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,6 +75,27 @@
|
|||||||
#inventory-table img.acquired.green{ /*32CD32*/
|
#inventory-table img.acquired.green{ /*32CD32*/
|
||||||
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
|
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
|
||||||
}
|
}
|
||||||
|
#inventory-table img.acquired.hotpink{ /*FF69B4*/
|
||||||
|
filter: sepia(100%) hue-rotate(300deg) saturate(10);
|
||||||
|
}
|
||||||
|
#inventory-table img.acquired.lightsalmon{ /*FFA07A*/
|
||||||
|
filter: sepia(100%) hue-rotate(347deg) saturate(10);
|
||||||
|
}
|
||||||
|
#inventory-table img.acquired.crimson{ /*DB143B*/
|
||||||
|
filter: sepia(100%) hue-rotate(318deg) saturate(10) brightness(0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table span{
|
||||||
|
color: #B4B4A0;
|
||||||
|
font-size: 40px;
|
||||||
|
max-width: 40px;
|
||||||
|
max-height: 40px;
|
||||||
|
filter: grayscale(100%) contrast(75%) brightness(30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table span.acquired{
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
#inventory-table div.image-stack{
|
#inventory-table div.image-stack{
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -13,3 +13,7 @@
|
|||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2, h4 {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import typing
|
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from colorsys import hsv_to_rgb
|
from colorsys import hsv_to_rgb
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
@@ -18,21 +17,23 @@ from .models import Room
|
|||||||
PLOT_WIDTH = 600
|
PLOT_WIDTH = 600
|
||||||
|
|
||||||
|
|
||||||
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
|
def get_db_data(known_games: set[str]) -> tuple[Counter[str], defaultdict[date, dict[str, int]]]:
|
||||||
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
|
games_played: defaultdict[date, dict[str, int]] = defaultdict(Counter)
|
||||||
games_played = defaultdict(Counter)
|
total_games: Counter[str] = Counter()
|
||||||
total_games = Counter()
|
|
||||||
cutoff = date.today() - timedelta(days=30)
|
cutoff = date.today() - timedelta(days=30)
|
||||||
room: Room
|
room: Room
|
||||||
for room in select(room for room in Room if room.creation_time >= cutoff):
|
for room in select(room for room in Room if room.creation_time >= cutoff):
|
||||||
for slot in room.seed.slots:
|
for slot in room.seed.slots:
|
||||||
if slot.game in known_games:
|
if slot.game in known_games:
|
||||||
total_games[slot.game] += 1
|
current_game = slot.game
|
||||||
games_played[room.creation_time.date()][slot.game] += 1
|
else:
|
||||||
|
current_game = "Other"
|
||||||
|
total_games[current_game] += 1
|
||||||
|
games_played[room.creation_time.date()][current_game] += 1
|
||||||
return total_games, games_played
|
return total_games, games_played
|
||||||
|
|
||||||
|
|
||||||
def get_color_palette(colors_needed: int) -> typing.List[RGB]:
|
def get_color_palette(colors_needed: int) -> list[RGB]:
|
||||||
colors = []
|
colors = []
|
||||||
# colors_needed +1 to prevent first and last color being too close to each other
|
# colors_needed +1 to prevent first and last color being too close to each other
|
||||||
colors_needed += 1
|
colors_needed += 1
|
||||||
@@ -47,8 +48,7 @@ def get_color_palette(colors_needed: int) -> typing.List[RGB]:
|
|||||||
return colors
|
return colors
|
||||||
|
|
||||||
|
|
||||||
def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
|
def create_game_played_figure(all_games_data: dict[date, dict[str, int]], game: str, color: RGB) -> figure:
|
||||||
game: str, color: RGB) -> figure:
|
|
||||||
occurences = []
|
occurences = []
|
||||||
days = [day for day, game_data in all_games_data.items() if game_data[game]]
|
days = [day for day, game_data in all_games_data.items() if game_data[game]]
|
||||||
for day in days:
|
for day in days:
|
||||||
@@ -84,7 +84,7 @@ def stats():
|
|||||||
days = sorted(games_played)
|
days = sorted(games_played)
|
||||||
|
|
||||||
color_palette = get_color_palette(len(total_games))
|
color_palette = get_color_palette(len(total_games))
|
||||||
game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
|
game_to_color: dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
|
||||||
|
|
||||||
for game in sorted(total_games):
|
for game in sorted(total_games):
|
||||||
occurences = []
|
occurences = []
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Page Not Found (404)</title>
|
<title>Page Not Found (404)</title>
|
||||||
@@ -13,5 +14,4 @@
|
|||||||
The page you're looking for doesn't exist.<br />
|
The page you're looking for doesn't exist.<br />
|
||||||
<a href="/">Click here to return to safety.</a>
|
<a href="/">Click here to return to safety.</a>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<title>{{ game }} Info</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
|
||||||
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/gameInfo.js") }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
{% include 'header/'+theme+'Header.html' %}
|
|
||||||
<div id="game-info" class="markdown" data-lang="{{ lang }}" data-game="{{ game | get_file_safe_name }}">
|
|
||||||
<!-- Populated my JS / MD -->
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if hint.finding_player == player %}
|
{% if hint.finding_player == player %}
|
||||||
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
|
||||||
{% elif get_slot_info(team, hint.finding_player).type == 2 %}
|
{% elif get_slot_info(hint.finding_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
|
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if hint.receiving_player == player %}
|
{% if hint.receiving_player == player %}
|
||||||
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
|
||||||
{% elif get_slot_info(team, hint.receiving_player).type == 2 %}
|
{% elif get_slot_info(hint.receiving_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
|
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Upload Multidata</title>
|
<title>Upload Multidata</title>
|
||||||
@@ -27,6 +28,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -58,8 +58,7 @@
|
|||||||
Open Log File...
|
Open Log File...
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% set log = get_log() -%}
|
{% set log, log_len = get_log() -%}
|
||||||
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
|
|
||||||
<div id="logger" style="white-space: pre">{{ log }}</div>
|
<div id="logger" style="white-space: pre">{{ log }}</div>
|
||||||
<script>
|
<script>
|
||||||
let url = '{{ url_for('display_log', room = room.id) }}';
|
let url = '{{ url_for('display_log', room = room.id) }}';
|
||||||
@@ -178,8 +177,15 @@
|
|||||||
})
|
})
|
||||||
.then(text => new DOMParser().parseFromString(text, 'text/html'))
|
.then(text => new DOMParser().parseFromString(text, 'text/html'))
|
||||||
.then(newDocument => {
|
.then(newDocument => {
|
||||||
let el = newDocument.getElementById("host-room-info");
|
["host-room-info", "slots-table"].forEach(function(id) {
|
||||||
document.getElementById("host-room-info").innerHTML = el.innerHTML;
|
const newEl = newDocument.getElementById(id);
|
||||||
|
const oldEl = document.getElementById(id);
|
||||||
|
if (oldEl && newEl) {
|
||||||
|
oldEl.innerHTML = newEl.innerHTML;
|
||||||
|
} else if (newEl) {
|
||||||
|
console.warn(`Did not find element to replace for ${id}`)
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% block footer %}
|
{% block footer %}
|
||||||
<footer id="island-footer">
|
<footer id="island-footer">
|
||||||
<div id="copyright-notice">Copyright 2024 Archipelago</div>
|
<div id="copyright-notice">Copyright 2025 Archipelago</div>
|
||||||
<div id="links">
|
<div id="links">
|
||||||
<a href="/sitemap">Site Map</a>
|
<a href="/sitemap">Site Map</a>
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Archipelago</title>
|
<title>Archipelago</title>
|
||||||
@@ -57,5 +58,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
{% macro list_patches_room(room) %}
|
{% macro list_patches_room(room) %}
|
||||||
{% if room.seed.slots %}
|
{% if room.seed.slots %}
|
||||||
<table>
|
<table id="slots-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Id</th>
|
<th>Id</th>
|
||||||
@@ -26,30 +26,18 @@
|
|||||||
<td>{{ patch.game }}</td>
|
<td>{{ patch.game }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if patch.data %}
|
{% if patch.data %}
|
||||||
{% if patch.game == "Minecraft" %}
|
{% if patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download APMC File...</a>
|
|
||||||
{% elif patch.game == "Factorio" %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download Factorio Mod...</a>
|
|
||||||
{% elif patch.game == "Kingdom Hearts 2" %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download Kingdom Hearts 2 Mod...</a>
|
|
||||||
{% elif patch.game == "Ocarina of Time" %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download APZ5 File...</a>
|
|
||||||
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download APV6 File...</a>
|
Download APV6 File...</a>
|
||||||
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download APSM64EX File...</a>
|
Download APSM64EX File...</a>
|
||||||
{% elif patch.game | supports_apdeltapatch %}
|
{% elif patch.game == "Factorio" %}
|
||||||
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
|
Download Factorio Mod...</a>
|
||||||
|
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %}
|
||||||
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
||||||
Download Patch File...</a>
|
Download Patch File...</a>
|
||||||
{% elif patch.game == "Final Fantasy Mystic Quest" %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download APMQ File...</a>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
No file to download for this game.
|
No file to download for this game.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% include 'header/grassHeader.html' %}
|
{% set theme_name = theme|default("grass", true) %}
|
||||||
|
{% include "header/"+theme_name+"Header.html" %}
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -45,15 +45,15 @@
|
|||||||
{%- set current_sphere = loop.index %}
|
{%- set current_sphere = loop.index %}
|
||||||
{%- for player, sphere_location_ids in sphere.items() %}
|
{%- for player, sphere_location_ids in sphere.items() %}
|
||||||
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
|
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
|
||||||
{%- set finder_game = tracker_data.get_player_game(team, player) %}
|
{%- set finder_game = tracker_data.get_player_game(player) %}
|
||||||
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
|
{%- set player_location_data = tracker_data.get_player_locations(player) %}
|
||||||
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
|
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
|
||||||
<tr>
|
<tr>
|
||||||
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
|
{%- set item_id, receiver, item_flags = player_location_data[location_id] %}
|
||||||
{%- set receiver_game = tracker_data.get_player_game(team, receiver) %}
|
{%- set receiver_game = tracker_data.get_player_game(receiver) %}
|
||||||
<td>{{ current_sphere }}</td>
|
<td>{{ current_sphere }}</td>
|
||||||
<td>{{ tracker_data.get_player_name(team, player) }}</td>
|
<td>{{ tracker_data.get_player_name(player) }}</td>
|
||||||
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
|
<td>{{ tracker_data.get_player_name(receiver) }}</td>
|
||||||
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
|
<td>{{ tracker_data.item_id_to_name[receiver_game][item_id] }}</td>
|
||||||
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
|
<td>{{ tracker_data.location_id_to_name[finder_game][location_id] }}</td>
|
||||||
<td>{{ finder_game }}</td>
|
<td>{{ finder_game }}</td>
|
||||||
|
|||||||
@@ -22,14 +22,14 @@
|
|||||||
-%}
|
-%}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{% if get_slot_info(team, hint.finding_player).type == 2 %}
|
{% if get_slot_info(hint.finding_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if get_slot_info(team, hint.receiving_player).type == 2 %}
|
{% if get_slot_info(hint.receiving_player).type == 2 %}
|
||||||
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
||||||
|
|||||||
@@ -5,26 +5,29 @@
|
|||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/styleController.js") }}"></script>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Archipelago</title>
|
<title>Archipelago</title>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<div>
|
||||||
|
{% for message in messages | unique %}
|
||||||
|
<div class="user-message">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
{% with messages = get_flashed_messages() %}
|
{% block body %}
|
||||||
{% if messages %}
|
{% endblock %}
|
||||||
<div>
|
</main>
|
||||||
{% for message in messages | unique %}
|
|
||||||
<div class="user-message">{{ message }}</div>
|
{% if show_footer %}
|
||||||
{% endfor %}
|
{% include "islandFooter.html" %}
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -111,10 +111,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro ItemDict(option_name, option) %}
|
{% macro OptionCounter(option_name, option) %}
|
||||||
|
{% set relevant_keys = option.valid_keys %}
|
||||||
|
{% if not relevant_keys %}
|
||||||
|
{% if option.verify_item_name %}
|
||||||
|
{% set relevant_keys = world.item_names %}
|
||||||
|
{% elif option.verify_location_name %}
|
||||||
|
{% set relevant_keys = world.location_names %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
||||||
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
|
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
|
||||||
@@ -125,6 +134,7 @@
|
|||||||
|
|
||||||
{% macro OptionList(option_name, option) %}
|
{% macro OptionList(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
@@ -137,6 +147,7 @@
|
|||||||
|
|
||||||
{% macro LocationSet(option_name, option) %}
|
{% macro LocationSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for group_name in world.location_name_groups.keys()|sort %}
|
{% for group_name in world.location_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everywhere" %}
|
{% if group_name != "Everywhere" %}
|
||||||
@@ -160,6 +171,7 @@
|
|||||||
|
|
||||||
{% macro ItemSet(option_name, option) %}
|
{% macro ItemSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for group_name in world.item_name_groups.keys()|sort %}
|
{% for group_name in world.item_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everything" %}
|
{% if group_name != "Everything" %}
|
||||||
@@ -183,6 +195,7 @@
|
|||||||
|
|
||||||
{% macro OptionSet(option_name, option) %}
|
{% macro OptionSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
@@ -213,7 +226,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro RandomizeButton(option_name, option) %}
|
{% macro RandomizeButton(option_name, option) %}
|
||||||
<div class="randomize-button" data-tooltip="Toggle randomization for this option!">
|
<div class="randomize-button" data-tooltip="Pick a random value for this option.">
|
||||||
<label for="random-{{ option_name }}">
|
<label for="random-{{ option_name }}">
|
||||||
<input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} />
|
<input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} />
|
||||||
🎲
|
🎲
|
||||||
|
|||||||
@@ -93,8 +93,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option) }}
|
option.valid_keys or option.verify_item_name or option.verify_location_name
|
||||||
|
) %}
|
||||||
|
{{ inputs.OptionCounter(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
@@ -133,8 +135,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option) }}
|
option.valid_keys or option.verify_item_name or option.verify_location_name
|
||||||
|
) %}
|
||||||
|
{{ inputs.OptionCounter(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Generation failed, please retry.</title>
|
<title>Generation failed, please retry.</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/waitSeed.css') }}"/>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% include 'header/oceanIslandHeader.html' %}
|
{% include 'header/oceanIslandHeader.html' %}
|
||||||
<div id="wait-seed-wrapper" class="grass-island">
|
<div id="wait-seed-wrapper" class="grass-island">
|
||||||
<div id="wait-seed">
|
<div id="wait-seed">
|
||||||
<h1>Generation failed</h1>
|
<h1>Generation Failed</h1>
|
||||||
<h2>please retry</h2>
|
<h2>Please try again!</h2>
|
||||||
{{ seed_error }}
|
<p>{{ seed_error }}</p>
|
||||||
|
<h4>More details:</h4>
|
||||||
|
<p>
|
||||||
|
<code class="grassy">{{ details }}</code>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
30
WebHostLib/templates/session.html
Normal file
30
WebHostLib/templates/session.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% include 'header/stoneHeader.html' %}
|
||||||
|
<title>Session</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="markdown">
|
||||||
|
{% if old_id is defined %}
|
||||||
|
<p>Your old code was:</p>
|
||||||
|
<code>{{ old_id }}</code>
|
||||||
|
<br>
|
||||||
|
{% endif %}
|
||||||
|
<p>The following code is your unique identifier, it binds your uploaded content, such as rooms and seeds to you.
|
||||||
|
Treat it like a combined login name and password.
|
||||||
|
You should save this securely if you ever need to restore access.
|
||||||
|
You can also paste it into another device to access your content from multiple devices / browsers.
|
||||||
|
Some browsers, such as Brave, will delete your identifier cookie on a timer.</p>
|
||||||
|
<code>{{ session["_id"] }}</code>
|
||||||
|
<br>
|
||||||
|
<p>
|
||||||
|
The following link can be used to set the identifier. Do not share the code or link with others. <br>
|
||||||
|
<a href="{{ url_for('set_session', _id=session['_id']) }}">
|
||||||
|
{{ url_for('set_session', _id=session['_id'], _external=True) }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -11,31 +11,32 @@
|
|||||||
<h1>Site Map</h1>
|
<h1>Site Map</h1>
|
||||||
<h2>Base Pages</h2>
|
<h2>Base Pages</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/discord">Discord Link</a></li>
|
<li><a href="{{ url_for('discord') }}">Discord Link</a></li>
|
||||||
<li><a href="/faq/en">F.A.Q. Page</a></li>
|
<li><a href="{{ url_for('faq', lang='en') }}">F.A.Q. Page</a></li>
|
||||||
<li><a href="/favicon.ico">Favicon</a></li>
|
<li><a href="{{ url_for('favicon') }}">Favicon</a></li>
|
||||||
<li><a href="/generate">Generate Game Page</a></li>
|
<li><a href="{{ url_for('generate') }}">Generate Game Page</a></li>
|
||||||
<li><a href="/">Homepage</a></li>
|
<li><a href="{{ url_for('landing') }}">Homepage</a></li>
|
||||||
<li><a href="/uploads">Host Game Page</a></li>
|
<li><a href="{{ url_for('uploads') }}">Host Game Page</a></li>
|
||||||
<li><a href="/datapackage">Raw Data Package</a></li>
|
<li><a href="{{ url_for('get_datapackage') }}">Raw Data Package</a></li>
|
||||||
<li><a href="{{ url_for('check')}}">Settings Validator</a></li>
|
<li><a href="{{ url_for('check') }}">Settings Validator</a></li>
|
||||||
<li><a href="/sitemap">Site Map</a></li>
|
<li><a href="{{ url_for('get_sitemap') }}">Site Map</a></li>
|
||||||
<li><a href="/start-playing">Start Playing</a></li>
|
<li><a href="{{ url_for('start_playing') }}">Start Playing</a></li>
|
||||||
<li><a href="/games">Supported Games Page</a></li>
|
<li><a href="{{ url_for('games') }}">Supported Games Page</a></li>
|
||||||
<li><a href="/tutorial">Tutorials Page</a></li>
|
<li><a href="{{ url_for('tutorial_landing') }}">Tutorials Page</a></li>
|
||||||
<li><a href="/user-content">User Content</a></li>
|
<li><a href="{{ url_for('user_content') }}">User Content</a></li>
|
||||||
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
|
<li><a href="{{ url_for('stats') }}">Game Statistics</a></li>
|
||||||
<li><a href="/glossary/en">Glossary</a></li>
|
<li><a href="{{ url_for('glossary', lang='en') }}">Glossary</a></li>
|
||||||
|
<li><a href="{{ url_for('show_session') }}">Session / Login</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Tutorials</h2>
|
<h2>Tutorials</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/tutorial/Archipelago/setup/en">Multiworld Setup Tutorial</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='setup_en') }}">Multiworld Setup Tutorial</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/mac/en">Setup Guide for Mac</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='mac_en') }}">Setup Guide for Mac</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/commands/en">Server and Client Commands</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='commands_en') }}">Server and Client Commands</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/advanced_settings/en">Advanced YAML Guide</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='advanced_settings_en') }}">Advanced YAML Guide</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/triggers/en">Triggers Guide</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='triggers_en') }}">Triggers Guide</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/plando/en">Plando Guide</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='plando_en') }}">Plando Guide</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Game Info Pages</h2>
|
<h2>Game Info Pages</h2>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Start Playing</title>
|
<title>Start Playing</title>
|
||||||
@@ -26,6 +27,4 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
{% include 'header/oceanHeader.html' %}
|
{% include 'header/oceanHeader.html' %}
|
||||||
<div id="games" class="markdown">
|
<div id="games" class="markdown">
|
||||||
<h1>Currently Supported Games</h1>
|
<h1>Currently Supported Games</h1>
|
||||||
|
<p>Below are the games that are currently included with the Archipelago software. To play a game that is not on
|
||||||
|
this page, please refer to the <a href="/tutorial/Archipelago/setup/en#playing-with-custom-worlds">playing with
|
||||||
|
custom worlds</a> section of the setup guide.</p>
|
||||||
<div class="js-only">
|
<div class="js-only">
|
||||||
<label for="game-search">Search for your game below!</label><br />
|
<label for="game-search">Search for your game below!</label><br />
|
||||||
<div class="page-controls">
|
<div class="page-controls">
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
{% include 'header/grassHeader.html' %}
|
{% include 'header/grassHeader.html' %}
|
||||||
<title>Option Templates (YAML)</title>
|
<title>Option Templates (YAML)</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
|
||||||
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>{{ player_name }}'s Tracker</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
|
|
||||||
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
|
|
||||||
<div style="margin-bottom: 0.5rem">
|
|
||||||
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
|
||||||
<table id="inventory-table">
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
|
|
||||||
<td><img src="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
|
|
||||||
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></td>
|
|
||||||
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
|
|
||||||
title="Progressive Resource Crafting" /></td>
|
|
||||||
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
|
|
||||||
<td>
|
|
||||||
<div class="counted-item">
|
|
||||||
<img src="{{ icons['Ender Pearl'] }}" class="{{ 'acquired' if '3 Ender Pearls' in acquired_items }}" title="Ender Pearls" />
|
|
||||||
<div class="item-count">{{ pearls_count }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons['Bucket'] }}" class="{{ 'acquired' if 'Bucket' in acquired_items }}" title="Bucket" /></td>
|
|
||||||
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Archery' in acquired_items }}" title="Archery" /></td>
|
|
||||||
<td><img src="{{ icons['Shield'] }}" class="{{ 'acquired' if 'Shield' in acquired_items }}" title="Shield" /></td>
|
|
||||||
<td><img src="{{ icons['Red Bed'] }}" class="{{ 'acquired' if 'Bed' in acquired_items }}" title="Bed" /></td>
|
|
||||||
<td><img src="{{ icons['Water Bottle'] }}" class="{{ 'acquired' if 'Bottles' in acquired_items }}" title="Bottles" /></td>
|
|
||||||
<td>
|
|
||||||
<div class="counted-item">
|
|
||||||
<img src="{{ icons['Netherite Scrap'] }}" class="{{ 'acquired' if '8 Netherite Scrap' in acquired_items }}" title="Netherite Scrap" />
|
|
||||||
<div class="item-count">{{ scrap_count }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons['Flint and Steel'] }}" class="{{ 'acquired' if 'Flint and Steel' in acquired_items }}" title="Flint and Steel" /></td>
|
|
||||||
<td><img src="{{ icons['Enchanting Table'] }}" class="{{ 'acquired' if 'Enchanting' in acquired_items }}" title="Enchanting" /></td>
|
|
||||||
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
|
|
||||||
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
|
|
||||||
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
|
|
||||||
<td>
|
|
||||||
<div class="counted-item">
|
|
||||||
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
|
|
||||||
<div class="item-count">{{ shard_count }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
|
|
||||||
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
|
|
||||||
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
|
|
||||||
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
|
|
||||||
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table id="location-table">
|
|
||||||
{% for area in checks_done %}
|
|
||||||
<tr class="location-category" id="{{area}}-header">
|
|
||||||
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
|
|
||||||
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
|
|
||||||
</tr>
|
|
||||||
<tbody class="locations hide" id="{{area}}">
|
|
||||||
{% for location in location_info[area] %}
|
|
||||||
<tr>
|
|
||||||
<td class="location-name">{{ location }}</td>
|
|
||||||
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user