mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-07 15:13:52 -08:00
Compare commits
529 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d83da1b818 | ||
|
|
0de09cd794 | ||
|
|
48c201af19 | ||
|
|
b0300d3063 | ||
|
|
e0e34894a3 | ||
|
|
18e3a8911f | ||
|
|
c505b1c32c | ||
|
|
e22e434258 | ||
|
|
8b91f9ff72 | ||
|
|
fadcfbdfea | ||
|
|
3c4c294f9c | ||
|
|
27a7e538df | ||
|
|
cb0cadcc5f | ||
|
|
2e1035a29f | ||
|
|
21c7f3cd92 | ||
|
|
13b6a5f4b2 | ||
|
|
78e8082a6f | ||
|
|
1de91fab67 | ||
|
|
4ef5436559 | ||
|
|
f2a6a769b0 | ||
|
|
8a767bd2ad | ||
|
|
7df243b860 | ||
|
|
f35d91933b | ||
|
|
286769a0f3 | ||
|
|
1dd91ec85b | ||
|
|
6adeb8b95e | ||
|
|
3e0d42bf9e | ||
|
|
41e22dabda | ||
|
|
39e7ee315e | ||
|
|
3e032e6cd6 | ||
|
|
609f4af600 | ||
|
|
4c27e35445 | ||
|
|
b0c967c039 | ||
|
|
c51da00bfb | ||
|
|
f3389f5d8b | ||
|
|
3b1971be66 | ||
|
|
4cb518930c | ||
|
|
c835bff570 | ||
|
|
6ee02fc62d | ||
|
|
8095f922bc | ||
|
|
77e5f3733e | ||
|
|
c47687dd21 | ||
|
|
8662433142 | ||
|
|
5f073c2a76 | ||
|
|
c5d67dd97a | ||
|
|
9b421450b1 | ||
|
|
a6740e7be3 | ||
|
|
65ef35f1b4 | ||
|
|
520253e762 | ||
|
|
aa3614a32b | ||
|
|
94492c45cb | ||
|
|
8f261bb27c | ||
|
|
ddd08342c8 | ||
|
|
c7db213ee9 | ||
|
|
220248dd3d | ||
|
|
5932160f15 | ||
|
|
76e0619b79 | ||
|
|
646a52a2e7 | ||
|
|
e1322df8b0 | ||
|
|
092a9dc6bd | ||
|
|
9f71fe707f | ||
|
|
b8311a62e7 | ||
|
|
13830ff4cb | ||
|
|
c1b858b2cf | ||
|
|
a035ac579c | ||
|
|
20c10e33c4 | ||
|
|
a4e4ce1c72 | ||
|
|
983936af8c | ||
|
|
62dfeac441 | ||
|
|
b81e1a228a | ||
|
|
5899920e48 | ||
|
|
8dee460397 | ||
|
|
cda54e0bea | ||
|
|
0554bf4e2d | ||
|
|
b92803e77f | ||
|
|
69e83071ff | ||
|
|
875765e6dc | ||
|
|
db56e26df9 | ||
|
|
5a88641228 | ||
|
|
16559e7595 | ||
|
|
d594d5d4a7 | ||
|
|
e950a2fa58 | ||
|
|
1df38cb782 | ||
|
|
c6400b6673 | ||
|
|
dbf2325c01 | ||
|
|
dd5b25399a | ||
|
|
8178ee4e58 | ||
|
|
ad1b41ea81 | ||
|
|
efd8528db0 | ||
|
|
e54a15978f | ||
|
|
d78b9ded2d | ||
|
|
53e8130c9c | ||
|
|
55c70a5ba8 | ||
|
|
ebbdd7bfda | ||
|
|
863f161466 | ||
|
|
9305ecb3bc | ||
|
|
0002bb8e5a | ||
|
|
b42fb77451 | ||
|
|
5a8e166289 | ||
|
|
5fa719143c | ||
|
|
a906f139c3 | ||
|
|
56363ea7e7 | ||
|
|
01e1e1fe11 | ||
|
|
4477dc7a66 | ||
|
|
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 |
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
|
||||
6
.github/pyright-config.json
vendored
6
.github/pyright-config.json
vendored
@@ -2,11 +2,15 @@
|
||||
"include": [
|
||||
"../BizHawkClient.py",
|
||||
"../Patch.py",
|
||||
"../rule_builder/cached_world.py",
|
||||
"../rule_builder/options.py",
|
||||
"../rule_builder/rules.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/general/test_rule_builder.py",
|
||||
"../test/multiworld/__init__.py",
|
||||
"../test/multiworld/test_multiworlds.py",
|
||||
"../test/netutils/__init__.py",
|
||||
@@ -29,7 +33,7 @@
|
||||
"reportMissingImports": true,
|
||||
"reportMissingTypeStubs": true,
|
||||
|
||||
"pythonVersion": "3.10",
|
||||
"pythonVersion": "3.11",
|
||||
"pythonPlatform": "Windows",
|
||||
|
||||
"executionEnvironments": [
|
||||
|
||||
2
.github/workflows/analyze-modified-files.yml
vendored
2
.github/workflows/analyze-modified-files.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
if: env.diff != ''
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.11'
|
||||
|
||||
- name: "Install dependencies"
|
||||
if: env.diff != ''
|
||||
|
||||
24
.github/workflows/build.yml
vendored
24
.github/workflows/build.yml
vendored
@@ -1,4 +1,5 @@
|
||||
# This workflow will build a release-like distribution when manually dispatched
|
||||
# This workflow will build a release-like distribution when manually dispatched:
|
||||
# a Windows x64 7zip, a Windows x64 Installer, a Linux AppImage and a Linux binary .tar.gz.
|
||||
|
||||
name: Build
|
||||
|
||||
@@ -9,17 +10,25 @@ on:
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
- '*.iss'
|
||||
- 'worlds/*/archipelago.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build.yml'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
- '*.iss'
|
||||
- 'worlds/*/archipelago.json'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
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'
|
||||
@@ -98,7 +107,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
cp Players/Templates/Clique.yaml Players/
|
||||
cp Players/Templates/VVVVVV.yaml Players/
|
||||
timeout 30 ./ArchipelagoGenerate
|
||||
- name: Store 7z
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -134,10 +143,13 @@ jobs:
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
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
|
||||
./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
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
@@ -189,7 +201,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
cd build/exe*
|
||||
cp Players/Templates/Clique.yaml Players/
|
||||
cp Players/Templates/VVVVVV.yaml Players/
|
||||
timeout 30 ./ArchipelagoGenerate
|
||||
- name: Store AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
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
|
||||
1
.github/workflows/label-pull-requests.yml
vendored
1
.github/workflows/label-pull-requests.yml
vendored
@@ -12,7 +12,6 @@ env:
|
||||
jobs:
|
||||
labeler:
|
||||
name: 'Apply content-based labels'
|
||||
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'synchronize'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v5
|
||||
|
||||
17
.github/workflows/release.yml
vendored
17
.github/workflows/release.yml
vendored
@@ -5,11 +5,17 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
- 'v?[0-9]+.[0-9]+.[0-9]*'
|
||||
|
||||
env:
|
||||
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'
|
||||
@@ -122,10 +128,13 @@ jobs:
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
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
|
||||
./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
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
|
||||
20
.github/workflows/unittests.yml
vendored
20
.github/workflows/unittests.yml
vendored
@@ -8,18 +8,24 @@ on:
|
||||
paths:
|
||||
- '**'
|
||||
- '!docs/**'
|
||||
- '!deploy/**'
|
||||
- '!setup.py'
|
||||
- '!Dockerfile'
|
||||
- '!*.iss'
|
||||
- '!.gitignore'
|
||||
- '!.dockerignore'
|
||||
- '!.github/workflows/**'
|
||||
- '.github/workflows/unittests.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**'
|
||||
- '!docs/**'
|
||||
- '!deploy/**'
|
||||
- '!setup.py'
|
||||
- '!Dockerfile'
|
||||
- '!*.iss'
|
||||
- '!.gitignore'
|
||||
- '!.dockerignore'
|
||||
- '!.github/workflows/**'
|
||||
- '.github/workflows/unittests.yml'
|
||||
|
||||
@@ -33,15 +39,15 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python:
|
||||
- {version: '3.10'}
|
||||
- {version: '3.11'}
|
||||
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10
|
||||
- {version: '3.12'}
|
||||
- {version: '3.13'}
|
||||
include:
|
||||
- python: {version: '3.10'} # old compat
|
||||
- python: {version: '3.11'} # old compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.12'} # current
|
||||
- python: {version: '3.13'} # current
|
||||
os: windows-latest
|
||||
- python: {version: '3.12'} # current
|
||||
- python: {version: '3.13'} # current
|
||||
os: macos-latest
|
||||
|
||||
steps:
|
||||
@@ -53,7 +59,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
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 Launcher.py --update_settings # make sure host.yaml exists for tests
|
||||
- name: Unittests
|
||||
@@ -69,7 +75,7 @@ jobs:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
python:
|
||||
- {version: '3.12'} # current
|
||||
- {version: '3.13'} # current
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -63,7 +63,10 @@ Output Logs/
|
||||
/installdelete.iss
|
||||
/data/user.kv
|
||||
/datapackage
|
||||
/datapackage_export.json
|
||||
/custom_worlds
|
||||
# stubgen output
|
||||
/out/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
24
.run/Build APWorlds.run.xml
Normal file
24
.run/Build APWorlds.run.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Build APWorlds" 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>
|
||||
@@ -407,6 +407,7 @@ async def atari_sync_task(ctx: AdventureContext):
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
except CancelledError:
|
||||
pass
|
||||
|
||||
330
BaseClasses.py
330
BaseClasses.py
@@ -5,12 +5,13 @@ import functools
|
||||
import logging
|
||||
import random
|
||||
import secrets
|
||||
import warnings
|
||||
from argparse import Namespace
|
||||
from collections import Counter, deque
|
||||
from collections.abc import Collection, MutableSequence
|
||||
from collections import Counter, deque, defaultdict
|
||||
from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableSequence, Set
|
||||
from enum import IntEnum, IntFlag
|
||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
|
||||
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
|
||||
from typing import (AbstractSet, Any, ClassVar, Dict, List, Literal, NamedTuple,
|
||||
Optional, Protocol, Tuple, Union, TYPE_CHECKING, overload)
|
||||
import dataclasses
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
@@ -21,6 +22,7 @@ import Utils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from entrance_rando import ERPlacementState
|
||||
from rule_builder.rules import Rule
|
||||
from worlds import AutoWorld
|
||||
|
||||
|
||||
@@ -84,7 +86,7 @@ class MultiWorld():
|
||||
local_items: Dict[int, Options.LocalItems]
|
||||
non_local_items: Dict[int, Options.NonLocalItems]
|
||||
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
||||
completion_condition: Dict[int, Callable[[CollectionState], bool]]
|
||||
completion_condition: Dict[int, CollectionRule]
|
||||
indirect_connections: Dict[Region, Set[Entrance]]
|
||||
exclude_locations: Dict[int, Options.ExcludeLocations]
|
||||
priority_locations: Dict[int, Options.PriorityLocations]
|
||||
@@ -153,17 +155,11 @@ class MultiWorld():
|
||||
self.algorithm = 'balanced'
|
||||
self.groups = {}
|
||||
self.regions = self.RegionManager(players)
|
||||
self.shops = []
|
||||
self.itempool = []
|
||||
self.seed = None
|
||||
self.seed_name: str = "Unavailable"
|
||||
self.precollected_items = {player: [] for player in self.player_ids}
|
||||
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.customitemarray = []
|
||||
self.shuffle_ganon = True
|
||||
@@ -182,7 +178,7 @@ class MultiWorld():
|
||||
set_player_attr('completion_condition', lambda state: True)
|
||||
self.worlds = {}
|
||||
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
|
||||
|
||||
def get_all_ids(self) -> Tuple[int, ...]:
|
||||
@@ -227,17 +223,8 @@ class MultiWorld():
|
||||
self.seed_name = name if name else str(self.seed)
|
||||
|
||||
def set_options(self, args: Namespace) -> None:
|
||||
# TODO - remove this section once all worlds use options dataclasses
|
||||
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.", True)
|
||||
option.update(getattr(args, option_key, {}))
|
||||
setattr(self, option_key, option)
|
||||
|
||||
for player in self.player_ids:
|
||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||
self.worlds[player] = world_type(self, player)
|
||||
@@ -275,6 +262,7 @@ class MultiWorld():
|
||||
"local_items": set(item_link.get("local_items", [])),
|
||||
"non_local_items": set(item_link.get("non_local_items", [])),
|
||||
"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():
|
||||
@@ -298,6 +286,8 @@ class MultiWorld():
|
||||
|
||||
for group_name, item_link in item_links.items():
|
||||
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["item_pool"] = item_link["item_pool"]
|
||||
@@ -438,12 +428,27 @@ class MultiWorld():
|
||||
def get_location(self, location_name: str, player: int) -> Location:
|
||||
return self.regions.location_cache[player][location_name]
|
||||
|
||||
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False,
|
||||
def get_all_state(self, use_cache: bool | None = None, allow_partial_entrances: bool = False,
|
||||
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
|
||||
cached = getattr(self, "_all_state", None)
|
||||
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.
|
||||
|
||||
: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:
|
||||
@@ -456,8 +461,6 @@ class MultiWorld():
|
||||
if perform_sweep:
|
||||
ret.sweep_for_advancements()
|
||||
|
||||
if use_cache:
|
||||
self._all_state = ret
|
||||
return ret
|
||||
|
||||
def get_items(self) -> List[Item]:
|
||||
@@ -571,26 +574,9 @@ class MultiWorld():
|
||||
if self.has_beaten_game(state):
|
||||
return True
|
||||
|
||||
base_locations = self.get_locations() if locations is None else locations
|
||||
prog_locations = {location for location in base_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):
|
||||
return True
|
||||
|
||||
@@ -706,6 +692,12 @@ class MultiWorld():
|
||||
sphere.append(locations.pop(n))
|
||||
|
||||
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
|
||||
logging.warning(f"Could not access required locations for accessibility check."
|
||||
f" Missing: {locations}")
|
||||
@@ -775,7 +767,7 @@ class CollectionState():
|
||||
else:
|
||||
self._update_reachable_regions_auto_indirect_conditions(player, queue)
|
||||
|
||||
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque):
|
||||
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque[Entrance]):
|
||||
reachable_regions = self.reachable_regions[player]
|
||||
blocked_connections = self.blocked_connections[player]
|
||||
# run BFS on all connections, and keep track of those blocked by missing items
|
||||
@@ -793,13 +785,16 @@ class CollectionState():
|
||||
blocked_connections.update(new_region.exits)
|
||||
queue.extend(new_region.exits)
|
||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||
self.multiworld.worlds[player].reached_region(self, new_region)
|
||||
|
||||
# Retry connections if the new region can unblock them
|
||||
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
|
||||
if new_entrance in blocked_connections and new_entrance not in queue:
|
||||
queue.append(new_entrance)
|
||||
entrances = self.multiworld.indirect_connections.get(new_region)
|
||||
if entrances is not None:
|
||||
relevant_entrances = entrances.intersection(blocked_connections)
|
||||
relevant_entrances.difference_update(queue)
|
||||
queue.extend(relevant_entrances)
|
||||
|
||||
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque):
|
||||
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque[Entrance]):
|
||||
reachable_regions = self.reachable_regions[player]
|
||||
blocked_connections = self.blocked_connections[player]
|
||||
new_connection: bool = True
|
||||
@@ -821,6 +816,7 @@ class CollectionState():
|
||||
queue.extend(new_region.exits)
|
||||
self.path[new_region] = (new_region.name, self.path.get(connection, None))
|
||||
new_connection = True
|
||||
self.multiworld.worlds[player].reached_region(self, new_region)
|
||||
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
|
||||
queue.extend(blocked_connections)
|
||||
|
||||
@@ -869,20 +865,133 @@ class CollectionState():
|
||||
"Please switch over to sweep_for_advancements.")
|
||||
return self.sweep_for_advancements(locations)
|
||||
|
||||
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
if locations is None:
|
||||
locations = self.multiworld.get_filled_locations()
|
||||
reachable_advancements = True
|
||||
# since the loop has a good chance to run more than once, only filter the advancements once
|
||||
locations = {location for location in locations if location.advancement and location not in self.advancements}
|
||||
def _sweep_for_advancements_impl(self, advancements_per_player: List[Tuple[int, List[Location]]],
|
||||
yield_each_sweep: bool) -> Iterator[None]:
|
||||
"""
|
||||
The implementation for sweep_for_advancements is separated here because it returns a generator due to the use
|
||||
of a yield statement.
|
||||
"""
|
||||
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:
|
||||
reachable_advancements = {location for location in locations if location.can_reach(self)}
|
||||
locations -= reachable_advancements
|
||||
for advancement in reachable_advancements:
|
||||
for player, locations in advancements_per_player:
|
||||
if player not in players_to_check:
|
||||
next_advancements_per_player.append((player, locations))
|
||||
continue
|
||||
|
||||
# Accessibility of each location is checked first because a player's region accessibility cache becomes
|
||||
# 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)
|
||||
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
|
||||
self.collect(advancement.item, True, 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
|
||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||
@@ -1065,13 +1174,17 @@ class CollectionState():
|
||||
self.prog_items[player][item] = count
|
||||
|
||||
|
||||
CollectionRule = Callable[[CollectionState], bool]
|
||||
DEFAULT_COLLECTION_RULE: CollectionRule = staticmethod(lambda state: True)
|
||||
|
||||
|
||||
class EntranceType(IntEnum):
|
||||
ONE_WAY = 1
|
||||
TWO_WAY = 2
|
||||
|
||||
|
||||
class Entrance:
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
|
||||
hide_path: bool = False
|
||||
player: int
|
||||
name: str
|
||||
@@ -1150,13 +1263,13 @@ class Region:
|
||||
self.region_manager = region_manager
|
||||
|
||||
def __getitem__(self, index: int) -> Location:
|
||||
return self._list.__getitem__(index)
|
||||
return self._list[index]
|
||||
|
||||
def __setitem__(self, index: int, value: Location) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self._list.__len__()
|
||||
return len(self._list)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._list)
|
||||
@@ -1170,8 +1283,8 @@ class Region:
|
||||
|
||||
class LocationRegister(Register):
|
||||
def __delitem__(self, index: int) -> None:
|
||||
location: Location = self._list.__getitem__(index)
|
||||
self._list.__delitem__(index)
|
||||
location: Location = self._list[index]
|
||||
del self._list[index]
|
||||
del(self.region_manager.location_cache[location.player][location.name])
|
||||
|
||||
def insert(self, index: int, value: Location) -> None:
|
||||
@@ -1182,8 +1295,8 @@ class Region:
|
||||
|
||||
class EntranceRegister(Register):
|
||||
def __delitem__(self, index: int) -> None:
|
||||
entrance: Entrance = self._list.__getitem__(index)
|
||||
self._list.__delitem__(index)
|
||||
entrance: Entrance = self._list[index]
|
||||
del self._list[index]
|
||||
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
|
||||
|
||||
def insert(self, index: int, value: Entrance) -> None:
|
||||
@@ -1242,8 +1355,7 @@ class Region:
|
||||
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
|
||||
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
||||
|
||||
def add_locations(self, locations: Dict[str, Optional[int]],
|
||||
location_type: Optional[type[Location]] = None) -> None:
|
||||
def add_locations(self, locations: Mapping[str, int | None], location_type: type[Location] | None = None) -> None:
|
||||
"""
|
||||
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
|
||||
location names to address.
|
||||
@@ -1259,7 +1371,7 @@ class Region:
|
||||
self,
|
||||
location_name: str,
|
||||
item_name: str | None = None,
|
||||
rule: Callable[[CollectionState], bool] | None = None,
|
||||
rule: CollectionRule | Rule[Any] | None = None,
|
||||
location_type: type[Location] | None = None,
|
||||
item_type: type[Item] | None = None,
|
||||
show_in_spoiler: bool = True,
|
||||
@@ -1287,7 +1399,7 @@ class Region:
|
||||
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
|
||||
self.multiworld.worlds[self.player].set_rule(event_location, rule)
|
||||
|
||||
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
|
||||
|
||||
@@ -1298,7 +1410,7 @@ class Region:
|
||||
return event_item
|
||||
|
||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
||||
rule: Optional[CollectionRule | Rule[Any]] = None) -> Entrance:
|
||||
"""
|
||||
Connects this Region to another Region, placing the provided rule on the connection.
|
||||
|
||||
@@ -1306,8 +1418,8 @@ class Region:
|
||||
:param name: name of the connection being created
|
||||
:param rule: callable to determine access of this connection to go from self to the exiting_region"""
|
||||
exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}")
|
||||
if rule:
|
||||
exit_.access_rule = rule
|
||||
if rule is not None:
|
||||
self.multiworld.worlds[self.player].set_rule(exit_, rule)
|
||||
exit_.connect(connecting_region)
|
||||
return exit_
|
||||
|
||||
@@ -1331,8 +1443,8 @@ class Region:
|
||||
entrance.connect(self)
|
||||
return entrance
|
||||
|
||||
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
|
||||
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
|
||||
def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
|
||||
rules: Mapping[str, CollectionRule | Rule[Any]] | None = None) -> List[Entrance]:
|
||||
"""
|
||||
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
||||
|
||||
@@ -1340,7 +1452,7 @@ class Region:
|
||||
created entrances will be named "self.name -> connecting_region"
|
||||
: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)
|
||||
return [
|
||||
self.connect(
|
||||
@@ -1371,7 +1483,7 @@ class Location:
|
||||
show_in_spoiler: bool = True
|
||||
progress_type: LocationProgressType = LocationProgressType.DEFAULT
|
||||
always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
access_rule: CollectionRule = DEFAULT_COLLECTION_RULE
|
||||
item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
|
||||
item: Optional[Item] = None
|
||||
|
||||
@@ -1430,31 +1542,47 @@ class Location:
|
||||
|
||||
|
||||
class ItemClassification(IntFlag):
|
||||
filler = 0b0000
|
||||
filler = 0b00000
|
||||
""" aka trash, as in filler items like ammo, currency etc """
|
||||
|
||||
progression = 0b0001
|
||||
progression = 0b00001
|
||||
""" Item that is logically relevant.
|
||||
Protects this item from being placed on excluded or unreachable locations. """
|
||||
|
||||
useful = 0b0010
|
||||
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 = 0b0100
|
||||
trap = 0b00100
|
||||
""" Item that is detrimental in some way. """
|
||||
|
||||
skip_balancing = 0b1000
|
||||
skip_balancing = 0b01000
|
||||
""" should technically never occur on its own
|
||||
Item that is logically relevant, but progression balancing should not touch.
|
||||
Typically currency or other counted items. """
|
||||
|
||||
progression_skip_balancing = 0b1001 # only progression gets balanced
|
||||
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:
|
||||
"""As Network API flag int."""
|
||||
return int(self & 0b0111)
|
||||
return int(self & 0b00111)
|
||||
|
||||
|
||||
class Item:
|
||||
@@ -1498,6 +1626,10 @@ class Item:
|
||||
def trap(self) -> bool:
|
||||
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)
|
||||
@@ -1598,9 +1730,10 @@ class Spoiler:
|
||||
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
||||
location.item.name, location.item.player, location.name, location.player) for location in
|
||||
sphere_candidates])
|
||||
if any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]):
|
||||
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
||||
f'Something went terribly wrong here.')
|
||||
if not multiworld.has_beaten_game(state):
|
||||
raise RuntimeError("During playthrough generation, the game was determined to be unbeatable. "
|
||||
"Something went terribly wrong here. "
|
||||
f"Unreachable progression items: {sphere_candidates}")
|
||||
else:
|
||||
self.unreachables = sphere_candidates
|
||||
break
|
||||
@@ -1734,6 +1867,9 @@ class Spoiler:
|
||||
Utils.__version__, self.multiworld.seed))
|
||||
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
|
||||
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')
|
||||
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
|
||||
|
||||
@@ -1742,6 +1878,9 @@ class Spoiler:
|
||||
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(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():
|
||||
write_option(f_option, option)
|
||||
|
||||
@@ -1778,7 +1917,8 @@ class Spoiler:
|
||||
if self.unreachables:
|
||||
outfile.write('\n\nUnreachable Progression Items:\n\n')
|
||||
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:
|
||||
outfile.write('\n\nPaths:\n\n')
|
||||
@@ -1805,7 +1945,7 @@ class Tutorial(NamedTuple):
|
||||
description: str
|
||||
language: str
|
||||
file_name: str
|
||||
link: str
|
||||
link: str # unused
|
||||
authors: List[str]
|
||||
|
||||
|
||||
|
||||
169
CommonClient.py
Normal file → Executable file
169
CommonClient.py
Normal file → Executable file
@@ -21,10 +21,10 @@ import Utils
|
||||
if __name__ == "__main__":
|
||||
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,
|
||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
||||
from Utils import Version, stream_input, async_start
|
||||
from Utils import gui_enabled, Version, stream_input, async_start
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
import os
|
||||
import ssl
|
||||
@@ -35,9 +35,6 @@ if typing.TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
# without terminal, we have to use gui mode
|
||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
||||
|
||||
|
||||
@Utils.cache_argsless
|
||||
def get_ssl_context():
|
||||
@@ -65,6 +62,8 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
|
||||
def _cmd_exit(self) -> bool:
|
||||
"""Close connections and client"""
|
||||
if self.ctx.ui:
|
||||
self.ctx.ui.stop()
|
||||
self.ctx.exit_event.set()
|
||||
return True
|
||||
|
||||
@@ -107,7 +106,9 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
return False
|
||||
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:
|
||||
continue
|
||||
if location_id < 0:
|
||||
@@ -128,43 +129,87 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
self.output("No missing location checks found.")
|
||||
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."""
|
||||
if not self.ctx.game:
|
||||
self.output("No game set, cannot determine existing items.")
|
||||
return False
|
||||
self.output(f"Item Names for {self.ctx.game}")
|
||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
||||
self.output(item_name)
|
||||
return self.output_datapackage_part("Item Names")
|
||||
|
||||
def _cmd_item_groups(self):
|
||||
"""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):
|
||||
def _cmd_locations(self) -> bool:
|
||||
"""List all location names for the currently running game."""
|
||||
if not self.ctx.game:
|
||||
self.output("No game set, cannot determine existing locations.")
|
||||
return False
|
||||
self.output(f"Location Names for {self.ctx.game}")
|
||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
||||
self.output(location_name)
|
||||
return self.output_datapackage_part("Location Names")
|
||||
|
||||
def _cmd_location_groups(self):
|
||||
"""List all location group names for the currently running game."""
|
||||
if not self.ctx.game:
|
||||
self.output("No game set, cannot determine existing location groups.")
|
||||
return False
|
||||
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 output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
|
||||
filter_key: str,
|
||||
name: str) -> bool:
|
||||
"""
|
||||
Logs an item or location group from the player's game's datapackage.
|
||||
|
||||
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."""
|
||||
self.ctx.ready = not self.ctx.ready
|
||||
if self.ctx.ready:
|
||||
@@ -174,6 +219,7 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
state = ClientStatus.CLIENT_CONNECTED
|
||||
self.output("Unreadied.")
|
||||
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||
return True
|
||||
|
||||
def default(self, raw: str):
|
||||
"""The default message parser to be used when parsing any messages that do not match a command"""
|
||||
@@ -201,6 +247,7 @@ class CommonContext:
|
||||
|
||||
# noinspection PyTypeChecker
|
||||
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
||||
assert isinstance(key, str), f"ctx.{self.lookup_type}_names used with an id, use the lookup_in_ helpers instead"
|
||||
return self._game_store[key]
|
||||
|
||||
def __len__(self) -> int:
|
||||
@@ -210,7 +257,7 @@ class CommonContext:
|
||||
return iter(self._game_store)
|
||||
|
||||
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:
|
||||
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
|
||||
@@ -275,7 +322,7 @@ class CommonContext:
|
||||
hint_cost: int | None
|
||||
"""Current Hint Cost per Hint from the server"""
|
||||
hint_points: int | None
|
||||
"""Current avaliable Hint Points from the server"""
|
||||
"""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)"""
|
||||
|
||||
@@ -378,6 +425,8 @@ class CommonContext:
|
||||
|
||||
self.jsontotextparser = JSONtoTextParser(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)
|
||||
|
||||
# execution
|
||||
@@ -523,6 +572,10 @@ class CommonContext:
|
||||
and not self.slot_concerns_self(print_json_packet["receiving"]) \
|
||||
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):
|
||||
logger.info(args["text"])
|
||||
|
||||
@@ -637,6 +690,24 @@ class CommonContext:
|
||||
for game, game_data in data_package["games"].items():
|
||||
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
|
||||
|
||||
def set_notify(self, *keys: str) -> None:
|
||||
@@ -788,9 +859,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
||||
|
||||
server_url = urllib.parse.urlparse(address)
|
||||
if server_url.username:
|
||||
ctx.username = server_url.username
|
||||
ctx.username = urllib.parse.unquote(server_url.username)
|
||||
if server_url.password:
|
||||
ctx.password = server_url.password
|
||||
ctx.password = urllib.parse.unquote(server_url.password)
|
||||
|
||||
def reconnect_hint() -> str:
|
||||
return ", type /connect to reconnect" if ctx.server_address else ""
|
||||
@@ -937,6 +1008,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.hint_points = args.get("hint_points", 0)
|
||||
ctx.consume_players_package(args["players"])
|
||||
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 = []
|
||||
if ctx.locations_checked:
|
||||
msgs.append({"cmd": "LocationChecks",
|
||||
@@ -1017,11 +1094,19 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.stored_data.update(args["keys"])
|
||||
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
|
||||
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":
|
||||
ctx.stored_data[args["key"]] = args["value"]
|
||||
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
|
||||
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"):
|
||||
ctx.current_energy_link_value = args["value"]
|
||||
if ctx.ui:
|
||||
|
||||
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" ]
|
||||
89
Fill.py
89
Fill.py
@@ -116,12 +116,23 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
else:
|
||||
# we filled all reachable spots.
|
||||
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
|
||||
swap_attempts = ((i, location, unsafe)
|
||||
for unsafe in (False, True)
|
||||
for i, location in enumerate(placements))
|
||||
for (i, location, unsafe) in swap_attempts:
|
||||
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
|
||||
# number of times we will swap an individual item to prevent this
|
||||
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
|
||||
@@ -130,9 +141,30 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
|
||||
location.item = None
|
||||
placed_item.location = None
|
||||
|
||||
for previous_safe_swap_state in previous_safe_swap_state_cache:
|
||||
# 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
|
||||
# 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.
|
||||
@@ -330,7 +362,12 @@ def fast_fill(multiworld: MultiWorld,
|
||||
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)
|
||||
minimal_players = {player for player in multiworld.player_ids if
|
||||
multiworld.worlds[player].options.accessibility == "minimal"}
|
||||
@@ -450,6 +487,12 @@ def distribute_early_items(multiworld: MultiWorld,
|
||||
|
||||
def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
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())
|
||||
multiworld.random.shuffle(fill_locations)
|
||||
# get items to distribute
|
||||
@@ -492,18 +535,50 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
single_player = multiworld.players == 1 and not multiworld.groups
|
||||
|
||||
if prioritylocations:
|
||||
regular_progression = []
|
||||
deprioritized_progression = []
|
||||
for item in progitempool:
|
||||
if item.deprioritized:
|
||||
deprioritized_progression.append(item)
|
||||
else:
|
||||
regular_progression.append(item)
|
||||
|
||||
# "priority fill"
|
||||
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
||||
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
|
||||
# try without deprioritized items in the mix at all. This means they need to be collected into state first.
|
||||
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:
|
||||
if prioritylocations and regular_progression:
|
||||
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
||||
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
||||
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
|
||||
# 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)
|
||||
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)
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
|
||||
168
Generate.py
168
Generate.py
@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
||||
|
||||
|
||||
def mystery_argparse():
|
||||
def mystery_argparse(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
from settings import get_settings
|
||||
settings = get_settings()
|
||||
defaults = settings.generator
|
||||
@@ -57,7 +57,7 @@ def mystery_argparse():
|
||||
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()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.skip_output and args.spoiler_only:
|
||||
parser.error("Cannot mix --skip_output and --spoiler_only")
|
||||
@@ -68,7 +68,7 @@ def mystery_argparse():
|
||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||
if not os.path.isabs(args.meta_file_path):
|
||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||
args.plando = PlandoOptions.from_option_string(args.plando)
|
||||
|
||||
return args
|
||||
|
||||
@@ -119,9 +119,9 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
else:
|
||||
meta_weights = None
|
||||
|
||||
|
||||
player_id = 1
|
||||
player_files = {}
|
||||
player_id: int = 1
|
||||
player_files: dict[int, str] = {}
|
||||
player_errors: list[str] = []
|
||||
for file in os.scandir(args.player_files_path):
|
||||
fname = file.name
|
||||
if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") and \
|
||||
@@ -137,7 +137,11 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
weights_cache[fname] = tuple(weights_for_file)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
||||
logging.exception(f"Exception reading weights in file {fname}")
|
||||
player_errors.append(
|
||||
f"{len(player_errors) + 1}. "
|
||||
f"File {fname} is invalid. Please fix your yaml.\n{Utils.get_all_causes(e)}"
|
||||
)
|
||||
|
||||
# sort dict for consistent results across platforms:
|
||||
weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())}
|
||||
@@ -152,6 +156,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
args.multi = max(player_id - 1, args.multi)
|
||||
|
||||
if args.multi == 0:
|
||||
if player_errors:
|
||||
errors = "\n\n".join(player_errors)
|
||||
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
|
||||
f"See logs for full tracebacks.\n\n{errors}")
|
||||
raise ValueError(
|
||||
"No individual player files found and number of players is 0. "
|
||||
"Provide individual player files or specify the number of players via host.yaml or --multi."
|
||||
@@ -161,28 +169,19 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
f"{seed_name} Seed {seed} with plando: {args.plando}")
|
||||
|
||||
if not weights_cache:
|
||||
if player_errors:
|
||||
errors = "\n\n".join(player_errors)
|
||||
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
|
||||
f"See logs for full tracebacks.\n\n{errors}")
|
||||
raise Exception(f"No weights found. "
|
||||
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||
f"A mix is also permitted.")
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||
erargs.seed = seed
|
||||
erargs.plando_options = args.plando
|
||||
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.spoiler_only = args.spoiler_only
|
||||
erargs.name = {}
|
||||
erargs.csv_output = args.csv_output
|
||||
|
||||
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
|
||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||
for fname, yamls in weights_cache.items()}
|
||||
args.outputname = seed_name
|
||||
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
|
||||
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
||||
args.name = {}
|
||||
|
||||
if meta_weights:
|
||||
for category_name, category_dict in meta_weights.items():
|
||||
@@ -198,52 +197,95 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
yaml[category][key] = option
|
||||
elif category_name not in yaml:
|
||||
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:
|
||||
yaml[category_name][key] = option
|
||||
|
||||
player_path_cache = {}
|
||||
settings_cache: dict[str, tuple[argparse.Namespace, ...] | None] = {fname: None for fname in weights_cache}
|
||||
if args.sameoptions:
|
||||
for fname, yamls in weights_cache.items():
|
||||
try:
|
||||
settings_cache[fname] = tuple(roll_settings(yaml, args.plando) for yaml in yamls)
|
||||
except Exception as e:
|
||||
logging.exception(f"Exception reading settings in file {fname}")
|
||||
player_errors.append(
|
||||
f"{len(player_errors) + 1}. "
|
||||
f"File {fname} is invalid. Please fix your yaml.\n{Utils.get_all_causes(e)}"
|
||||
)
|
||||
# Exit early here to avoid throwing the same errors again later
|
||||
if player_errors:
|
||||
errors = "\n\n".join(player_errors)
|
||||
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
|
||||
f"See logs for full tracebacks.\n\n{errors}")
|
||||
|
||||
player_path_cache: dict[int, str] = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||
name_counter = Counter()
|
||||
erargs.player_options = {}
|
||||
name_counter: Counter[str] = Counter()
|
||||
args.player_options = {}
|
||||
|
||||
player = 1
|
||||
while player <= args.multi:
|
||||
path = player_path_cache[player]
|
||||
if path:
|
||||
if not path:
|
||||
player_errors.append(f'No weights specified for player {player}')
|
||||
player += 1
|
||||
continue
|
||||
|
||||
for doc_index, yaml in enumerate(weights_cache[path]):
|
||||
name = yaml.get("name")
|
||||
try:
|
||||
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
||||
for settingsObject in settings:
|
||||
for k, v in vars(settingsObject).items():
|
||||
# Use the cached settings object if it exists, otherwise roll settings within the try-catch
|
||||
# Invariant: settings_cache[path] and weights_cache[path] have the same length
|
||||
cached = settings_cache[path]
|
||||
settings_object: argparse.Namespace = (cached[doc_index] if cached else roll_settings(yaml, args.plando))
|
||||
|
||||
for k, v in vars(settings_object).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(erargs, k)[player] = v
|
||||
getattr(args, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(erargs, k, {player: v})
|
||||
setattr(args, k, {player: v})
|
||||
except Exception as e:
|
||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||
|
||||
# name was not specified
|
||||
if player not in erargs.name:
|
||||
if player not in args.name:
|
||||
if path == args.weights_file_path:
|
||||
# weights file, so we need to make the name unique
|
||||
erargs.name[player] = f"Player{player}"
|
||||
args.name[player] = f"Player{player}"
|
||||
else:
|
||||
# use the filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
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
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e
|
||||
else:
|
||||
raise RuntimeError(f'No weights specified for player {player}')
|
||||
logging.exception(f"Exception reading settings in file {path} document #{doc_index + 1} "
|
||||
f"(name: {args.name.get(player, name)})")
|
||||
player_errors.append(
|
||||
f"{len(player_errors) + 1}. "
|
||||
f"File {path} document #{doc_index + 1} (name: {args.name.get(player, name)}) is invalid. "
|
||||
f"Please fix your yaml.\n{Utils.get_all_causes(e)}")
|
||||
|
||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
||||
# increment for each yaml document in the file
|
||||
player += 1
|
||||
|
||||
return erargs, seed
|
||||
if len(set(name.lower() for name in args.name.values())) != len(args.name):
|
||||
player_errors.append(
|
||||
f"{len(player_errors) + 1}. "
|
||||
f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}"
|
||||
)
|
||||
|
||||
if player_errors:
|
||||
errors = "\n\n".join(player_errors)
|
||||
raise ValueError(f"Encountered {len(player_errors)} error(s) in player files. "
|
||||
f"See logs for full tracebacks.\n\n{errors}")
|
||||
|
||||
return args, seed
|
||||
|
||||
|
||||
def read_weights_yamls(path) -> tuple[Any, ...]:
|
||||
@@ -320,7 +362,7 @@ class SafeFormatter(string.Formatter):
|
||||
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[str]):
|
||||
name_counter[name.lower()] += 1
|
||||
number = name_counter[name.lower()]
|
||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||
@@ -351,7 +393,9 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
||||
elif isinstance(new_value, list):
|
||||
cleaned_value.extend(new_value)
|
||||
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:
|
||||
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
|
||||
f" received {type(new_value).__name__}.")
|
||||
@@ -365,13 +409,18 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
||||
for element in new_value:
|
||||
cleaned_value.remove(element)
|
||||
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:
|
||||
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
|
||||
f" received {type(new_value).__name__}.")
|
||||
cleaned_weights[option_name] = cleaned_value
|
||||
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)
|
||||
weights.update(cleaned_weights)
|
||||
if new_options:
|
||||
@@ -394,6 +443,8 @@ def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
|
||||
if options[option_key].supports_weighting:
|
||||
return get_choice(option_key, category_dict)
|
||||
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}.")
|
||||
|
||||
|
||||
@@ -449,7 +500,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
||||
return weights
|
||||
|
||||
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type[Options.Option], plando_options: PlandoOptions):
|
||||
try:
|
||||
if option_key in game_weights:
|
||||
if not option.supports_weighting:
|
||||
@@ -495,7 +546,22 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
if required_plando_options:
|
||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||
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()
|
||||
for option_key in Options.PerGameCommonOptions.type_hints:
|
||||
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
|
||||
|
||||
@@ -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()
|
||||
21
Launcher.py
21
Launcher.py
@@ -31,6 +31,10 @@ import settings
|
||||
import Utils
|
||||
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
|
||||
user_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_logging('Launcher')
|
||||
|
||||
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
|
||||
|
||||
|
||||
@@ -75,11 +79,16 @@ def open_patch():
|
||||
launch([*exe, file], component.cli)
|
||||
|
||||
|
||||
def generate_yamls():
|
||||
def generate_yamls(*args):
|
||||
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")
|
||||
generate_yaml_templates(target, False)
|
||||
if not args.skip_open_folder:
|
||||
open_folder(target)
|
||||
|
||||
|
||||
@@ -213,12 +222,17 @@ def launch(exe, in_terminal=False):
|
||||
|
||||
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)
|
||||
startmenu=False, terminal=False, working_dir=wkdir, noexe=Utils.is_frozen())
|
||||
button.menu.dismiss()
|
||||
|
||||
|
||||
@@ -483,8 +497,7 @@ def main(args: argparse.Namespace | dict | None = None):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_logging('Launcher')
|
||||
Utils.freeze_support()
|
||||
multiprocessing.freeze_support()
|
||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Archipelago Launcher',
|
||||
|
||||
@@ -32,6 +32,7 @@ GAME_ALTTP = "A Link to the Past"
|
||||
WINDOW_MIN_HEIGHT = 525
|
||||
WINDOW_MIN_WIDTH = 425
|
||||
|
||||
|
||||
class AdjusterWorld(object):
|
||||
class AdjusterSubWorld(object):
|
||||
def __init__(self, random):
|
||||
@@ -40,7 +41,6 @@ class AdjusterWorld(object):
|
||||
def __init__(self, sprite_pool):
|
||||
import random
|
||||
self.sprite_pool = {1: sprite_pool}
|
||||
self.per_slot_randoms = {1: random}
|
||||
self.worlds = {1: self.AdjusterSubWorld(random)}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
def _get_help_string(self, action):
|
||||
return textwrap.dedent(action.help)
|
||||
|
||||
|
||||
# See argparse.BooleanOptionalAction
|
||||
class BooleanOptionalActionWithDisable(argparse.Action):
|
||||
def __init__(self,
|
||||
@@ -364,10 +365,10 @@ def run_sprite_update():
|
||||
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 = ""
|
||||
successful = True
|
||||
sprite_dir = user_path("data", "sprites", "alttpr")
|
||||
sprite_dir = user_path("data", "sprites", "alttp", "remote")
|
||||
os.makedirs(sprite_dir, exist_ok=True)
|
||||
ctx = get_cert_none_ssl_context()
|
||||
|
||||
@@ -377,11 +378,11 @@ def update_sprites(task, on_finish=None):
|
||||
on_finish(successful, resultmessage)
|
||||
|
||||
try:
|
||||
task.update_status("Downloading alttpr sprites list")
|
||||
with urlopen('https://alttpr.com/sprites', context=ctx) as response:
|
||||
task.update_status("Downloading remote sprites list")
|
||||
with urlopen(repository_url, context=ctx) as response:
|
||||
sprites_arr = json.loads(response.read().decode("utf-8"))
|
||||
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
|
||||
task.queue_event(finished)
|
||||
return
|
||||
@@ -389,13 +390,13 @@ def update_sprites(task, on_finish=None):
|
||||
try:
|
||||
task.update_status("Determining needed sprites")
|
||||
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"]
|
||||
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]
|
||||
|
||||
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
|
||||
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
|
||||
remote_filenames = [filename for (_, filename) in remote_sprites]
|
||||
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames]
|
||||
except Exception as e:
|
||||
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
|
||||
type(e).__name__, e)
|
||||
@@ -447,7 +448,7 @@ def update_sprites(task, on_finish=None):
|
||||
successful = False
|
||||
|
||||
if successful:
|
||||
resultmessage = "alttpr sprites updated successfully"
|
||||
resultmessage = "Remote sprites updated successfully"
|
||||
|
||||
task.queue_event(finished)
|
||||
|
||||
@@ -868,7 +869,7 @@ class SpriteSelector():
|
||||
def open_custom_sprite_dir(_evt):
|
||||
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)
|
||||
title_text = Label(custom_frametitle, text="Custom Sprites")
|
||||
@@ -877,8 +878,8 @@ class SpriteSelector():
|
||||
title_link.pack(side=LEFT)
|
||||
title_link.bind("<Button-1>", open_custom_sprite_dir)
|
||||
|
||||
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
|
||||
'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
|
||||
self.icon_section(remote_frametitle, self.remote_sprite_dir,
|
||||
'Remote sprites not found. Click "Update remote sprites" to download them.')
|
||||
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.')
|
||||
if not randomOnEvent:
|
||||
@@ -891,11 +892,18 @@ class SpriteSelector():
|
||||
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
|
||||
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))
|
||||
|
||||
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.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.pack(side=LEFT, padx=(0, 5))
|
||||
@@ -1055,7 +1063,7 @@ class SpriteSelector():
|
||||
for i, button in enumerate(frame.buttons):
|
||||
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.
|
||||
self.window.destroy()
|
||||
self.parent.update()
|
||||
@@ -1068,7 +1076,8 @@ class SpriteSelector():
|
||||
messagebox.showerror("Sprite Updater", resultmessage)
|
||||
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):
|
||||
sprite = filedialog.askopenfilename(
|
||||
@@ -1158,12 +1167,13 @@ class SpriteSelector():
|
||||
os.makedirs(self.custom_sprite_dir)
|
||||
|
||||
@property
|
||||
def alttpr_sprite_dir(self):
|
||||
return user_path("data", "sprites", "alttpr")
|
||||
def remote_sprite_dir(self):
|
||||
return user_path("data", "sprites", "alttp", "remote")
|
||||
|
||||
@property
|
||||
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):
|
||||
if not sprite.valid:
|
||||
|
||||
@@ -286,6 +286,7 @@ async def gba_sync_task(ctx: MMBN3Context):
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
|
||||
|
||||
68
Main.py
68
Main.py
@@ -1,10 +1,11 @@
|
||||
import collections
|
||||
from collections.abc import Mapping
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
@@ -12,8 +13,9 @@ import worlds
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, 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 Utils import __version__, output_path, version_tuple
|
||||
from Utils import __version__, output_path, restricted_dumps, version_tuple
|
||||
from settings import get_settings
|
||||
from worlds import AutoWorld
|
||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||
@@ -35,7 +37,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
|
||||
logger = logging.getLogger()
|
||||
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.game = args.game.copy()
|
||||
multiworld.player_name = args.name.copy()
|
||||
multiworld.sprite = args.sprite.copy()
|
||||
@@ -52,12 +54,17 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||
|
||||
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
||||
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
||||
world_classes = AutoWorld.AutoWorldRegister.world_types.values()
|
||||
|
||||
version_count = max(len(cls.world_version.as_simple_string()) for cls in world_classes)
|
||||
item_count = len(str(max(len(cls.item_names) for cls in world_classes)))
|
||||
location_count = len(str(max(len(cls.location_names) for cls in world_classes)))
|
||||
|
||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||
if not cls.hidden and len(cls.item_names) > 0:
|
||||
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
|
||||
logger.info(f" {name:{longest_name}}: "
|
||||
f"v{cls.world_version.as_simple_string():{version_count}} | "
|
||||
f"Items: {len(cls.item_names):{item_count}} | "
|
||||
f"Locations: {len(cls.location_names):{location_count}}")
|
||||
|
||||
del item_count, location_count
|
||||
@@ -92,6 +99,15 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
del local_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.')
|
||||
AutoWorld.call_all(multiworld, "create_regions")
|
||||
|
||||
@@ -99,12 +115,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
AutoWorld.call_all(multiworld, "create_items")
|
||||
|
||||
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")
|
||||
|
||||
for player in multiworld.player_ids:
|
||||
@@ -125,11 +135,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
|
||||
|
||||
# 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:
|
||||
locality_rules(multiworld)
|
||||
else:
|
||||
multiworld.worlds[1].options.non_local_items.value = set()
|
||||
multiworld.worlds[1].options.local_items.value = set()
|
||||
|
||||
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
|
||||
|
||||
@@ -173,7 +181,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
|
||||
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
|
||||
|
||||
logger.info("Running Item Plando.")
|
||||
@@ -238,11 +246,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
def write_multidata():
|
||||
import NetUtils
|
||||
from NetUtils import HintStatus
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
games = {}
|
||||
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
|
||||
slot_info = {}
|
||||
slot_data: dict[int, Mapping[str, Any]] = {}
|
||||
client_versions: dict[int, tuple[int, int, int]] = {}
|
||||
games: dict[int, str] = {}
|
||||
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())]]
|
||||
for slot in multiworld.player_ids:
|
||||
player_world: AutoWorld.World = multiworld.worlds[slot]
|
||||
@@ -257,7 +267,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
group_members=sorted(group["players"]))
|
||||
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
||||
for player, world_precollected in 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:
|
||||
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
||||
@@ -314,7 +326,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
if current_sphere:
|
||||
spheres.append(dict(current_sphere))
|
||||
|
||||
multidata = {
|
||||
multidata: NetUtils.MultiData = {
|
||||
"slot_data": slot_data,
|
||||
"slot_info": slot_info,
|
||||
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
|
||||
@@ -324,7 +336,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
"er_hint_data": er_hint_data,
|
||||
"precollected_items": precollected_items,
|
||||
"precollected_hints": precollected_hints,
|
||||
"version": tuple(version_tuple),
|
||||
"version": (version_tuple.major, version_tuple.minor, version_tuple.build),
|
||||
"tags": ["AP"],
|
||||
"minimum_versions": minimum_versions,
|
||||
"seed_name": multiworld.seed_name,
|
||||
@@ -332,13 +344,17 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
"datapackage": data_package,
|
||||
"race_mode": int(multiworld.is_race),
|
||||
}
|
||||
# TODO: change to `"version": version_tuple` after getting better serialization
|
||||
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:
|
||||
f.write(bytes([3])) # version of format
|
||||
f.write(multidata)
|
||||
f.write(serialized_multidata)
|
||||
|
||||
output_file_futures.append(pool.submit(write_multidata))
|
||||
if not check_accessibility_task.result():
|
||||
|
||||
@@ -5,18 +5,23 @@ import multiprocessing
|
||||
import warnings
|
||||
|
||||
|
||||
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
|
||||
if sys.platform in ("win32", "darwin") and not (3, 11, 9) <= sys.version_info < (3, 14, 0):
|
||||
# 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.10.15+ is supported.")
|
||||
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15):
|
||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. "
|
||||
"Official 3.11.9 through 3.13.x 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, 10, 1):
|
||||
elif not (3, 11, 0) <= sys.version_info < (3, 14, 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.10.1+ is supported.")
|
||||
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0 through 3.13.x is supported.")
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -70,11 +75,11 @@ def update_command():
|
||||
def install_pkg_resources(yes=False):
|
||||
try:
|
||||
import pkg_resources # noqa: F401
|
||||
except ImportError:
|
||||
except (AttributeError, ImportError):
|
||||
check_pip()
|
||||
if not yes:
|
||||
confirm("pkg_resources not found, press enter to install it")
|
||||
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
|
||||
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"])
|
||||
|
||||
|
||||
def update(yes: bool = False, force: bool = False) -> None:
|
||||
|
||||
294
MultiServer.py
294
MultiServer.py
@@ -32,7 +32,7 @@ if typing.TYPE_CHECKING:
|
||||
|
||||
import colorama
|
||||
import websockets
|
||||
from websockets.extensions.permessage_deflate import PerMessageDeflate
|
||||
from websockets.extensions.permessage_deflate import PerMessageDeflate, ServerPerMessageDeflateFactory
|
||||
try:
|
||||
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
||||
from pony.orm.dbapiprovider import OperationalError
|
||||
@@ -43,13 +43,22 @@ import NetUtils
|
||||
import Utils
|
||||
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||
SlotType, LocationStore, Hint, HintStatus
|
||||
SlotType, LocationStore, MultiData, Hint, HintStatus
|
||||
from BaseClasses import ItemClassification
|
||||
|
||||
|
||||
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):
|
||||
try:
|
||||
@@ -60,6 +69,12 @@ def remove_from_list(container, value):
|
||||
|
||||
|
||||
def pop_from_container(container, value):
|
||||
if isinstance(container, list) and isinstance(value, int) and len(container) <= value:
|
||||
return container
|
||||
|
||||
if isinstance(container, dict) and value not in container:
|
||||
return container
|
||||
|
||||
try:
|
||||
container.pop(value)
|
||||
except ValueError:
|
||||
@@ -125,8 +140,31 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
||||
|
||||
|
||||
class Client(Endpoint):
|
||||
version = Version(0, 0, 0)
|
||||
tags: typing.List[str]
|
||||
__slots__ = (
|
||||
"__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_start_inventory: bool
|
||||
no_items: bool
|
||||
@@ -135,6 +173,7 @@ class Client(Endpoint):
|
||||
|
||||
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
|
||||
super().__init__(socket)
|
||||
self.version = no_version
|
||||
self.auth = False
|
||||
self.team = None
|
||||
self.slot = None
|
||||
@@ -142,6 +181,11 @@ class Client(Endpoint):
|
||||
self.tags = []
|
||||
self.messageprocessor = client_message_processor(ctx, self)
|
||||
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
|
||||
def items_handling(self):
|
||||
@@ -179,6 +223,7 @@ class Context:
|
||||
"release_mode": str,
|
||||
"remaining_mode": str,
|
||||
"collect_mode": str,
|
||||
"countdown_mode": str,
|
||||
"item_cheat": bool,
|
||||
"compatibility": int}
|
||||
# team -> slot id -> list of clients authenticated to slot.
|
||||
@@ -208,8 +253,8 @@ class Context:
|
||||
|
||||
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",
|
||||
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
||||
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
||||
countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
|
||||
compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
||||
self.logger = logger
|
||||
super(Context, self).__init__()
|
||||
self.slot_info = {}
|
||||
@@ -242,6 +287,7 @@ class Context:
|
||||
self.release_mode: str = release_mode
|
||||
self.remaining_mode: str = remaining_mode
|
||||
self.collect_mode: str = collect_mode
|
||||
self.countdown_mode: str = countdown_mode
|
||||
self.item_cheat = item_cheat
|
||||
self.exit_event = asyncio.Event()
|
||||
self.client_activity_timers: typing.Dict[
|
||||
@@ -445,7 +491,7 @@ class Context:
|
||||
raise Utils.VersionException("Incompatible multidata.")
|
||||
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):
|
||||
|
||||
self.read_data = {}
|
||||
@@ -453,7 +499,7 @@ class Context:
|
||||
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
|
||||
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
||||
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}")
|
||||
self.generator_version = Version(*decoded_obj["version"])
|
||||
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
||||
@@ -546,6 +592,7 @@ class Context:
|
||||
|
||||
def _save(self, exit_save: bool = False) -> bool:
|
||||
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())
|
||||
with open(self.save_filename, "wb") as f:
|
||||
f.write(zlib.compress(encoded_save))
|
||||
@@ -626,6 +673,7 @@ class Context:
|
||||
"server_password": self.server_password, "password": self.password,
|
||||
"release_mode": self.release_mode,
|
||||
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
|
||||
"countdown_mode": self.countdown_mode,
|
||||
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
||||
|
||||
}
|
||||
@@ -660,6 +708,7 @@ class Context:
|
||||
self.release_mode = savedata["game_options"]["release_mode"]
|
||||
self.remaining_mode = savedata["game_options"]["remaining_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.compatibility = savedata["game_options"]["compatibility"]
|
||||
|
||||
@@ -752,7 +801,7 @@ class Context:
|
||||
return self.player_names[team, slot]
|
||||
|
||||
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."""
|
||||
if only_new:
|
||||
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
|
||||
@@ -767,8 +816,9 @@ class Context:
|
||||
if not hint.local and data not in concerns[hint.finding_player]:
|
||||
concerns[hint.finding_player].append(data)
|
||||
|
||||
# only remember hints that were not already found at the time of creation
|
||||
if not hint.found:
|
||||
# For !hint use cases, only hints that were not already found at the time of creation should be remembered
|
||||
# For LocationScouts use-cases, all hints should be remembered
|
||||
if not hint.found or persist_even_if_found:
|
||||
# since hints are bidirectional, finding player and receiving player,
|
||||
# we can check once if hint already exists
|
||||
if hint not in self.hints[team, hint.finding_player]:
|
||||
@@ -867,12 +917,6 @@ async def server(websocket: "ServerConnection", path: str = "/", ctx: Context =
|
||||
|
||||
|
||||
async def on_client_connected(ctx: Context, client: Client):
|
||||
players = []
|
||||
for team, clients in ctx.clients.items():
|
||||
for slot, connected_clients in clients.items():
|
||||
if connected_clients:
|
||||
name = ctx.player_names[team, slot]
|
||||
players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
|
||||
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
|
||||
games.add("Archipelago")
|
||||
await ctx.send_msgs(client, [{
|
||||
@@ -1133,8 +1177,13 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
||||
ctx.save()
|
||||
|
||||
|
||||
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
|
||||
-> typing.List[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 = []
|
||||
slots: typing.Set[int] = {slot}
|
||||
for group_id, group in ctx.groups.items():
|
||||
@@ -1150,25 +1199,39 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
||||
else:
|
||||
found = location_id in ctx.location_checks[team, finding_player]
|
||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||
new_status = auto_status
|
||||
|
||||
hint_status = status # Assign again because we're in a for loop
|
||||
if found:
|
||||
new_status = HintStatus.HINT_FOUND
|
||||
elif item_flags & ItemClassification.trap:
|
||||
new_status = HintStatus.HINT_AVOID
|
||||
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
||||
item_flags, new_status))
|
||||
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
|
||||
|
||||
|
||||
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
|
||||
-> typing.List[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]
|
||||
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
|
||||
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, auto_status: HintStatus) \
|
||||
-> typing.List[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]
|
||||
@@ -1178,13 +1241,16 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
|
||||
|
||||
found = seeked_location in ctx.location_checks[team, slot]
|
||||
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
|
||||
new_status = auto_status
|
||||
|
||||
if found:
|
||||
new_status = HintStatus.HINT_FOUND
|
||||
elif item_flags & ItemClassification.trap:
|
||||
new_status = HintStatus.HINT_AVOID
|
||||
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
|
||||
new_status)]
|
||||
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 []
|
||||
|
||||
|
||||
@@ -1298,7 +1364,11 @@ class CommandProcessor(metaclass=CommandMeta):
|
||||
argname += "=" + parameter.default
|
||||
argtext += argname
|
||||
argtext += " "
|
||||
s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n"
|
||||
method_doc = inspect.getdoc(method)
|
||||
if method_doc is None:
|
||||
method_doc = "(missing help text)"
|
||||
doctext = "\n ".join(method_doc.split("\n"))
|
||||
s += f"{self.marker}{command} {argtext}\n {doctext}\n"
|
||||
return s
|
||||
|
||||
def _cmd_help(self):
|
||||
@@ -1327,19 +1397,6 @@ class CommandProcessor(metaclass=CommandMeta):
|
||||
class CommonCommandProcessor(CommandProcessor):
|
||||
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):
|
||||
"""List all current options. Warning: lists password."""
|
||||
self.output("Current options:")
|
||||
@@ -1481,6 +1538,23 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
" You can ask the server admin for a /collect")
|
||||
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:
|
||||
"""List remaining items in your game, but not their location or recipient"""
|
||||
if self.ctx.remaining_mode == "enabled":
|
||||
@@ -1608,7 +1682,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
|
||||
points_available = get_client_points(self.ctx, self.client)
|
||||
cost = self.ctx.get_hint_cost(self.client.slot)
|
||||
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
|
||||
if not input_text:
|
||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||
self.ctx.hints[self.client.team, self.client.slot]}
|
||||
@@ -1634,9 +1707,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
|
||||
hints = []
|
||||
elif not for_location:
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||
else:
|
||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
|
||||
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
|
||||
|
||||
else:
|
||||
game = self.ctx.games[self.client.slot]
|
||||
@@ -1656,16 +1729,18 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
hints = []
|
||||
for item_name in self.ctx.item_name_groups[game][hint_name]:
|
||||
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
|
||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
|
||||
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||
elif hint_name in self.ctx.location_name_groups[game]: # location group name
|
||||
hints = []
|
||||
for loc_name in self.ctx.location_name_groups[game][hint_name]:
|
||||
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, auto_status))
|
||||
hints.extend(
|
||||
collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)
|
||||
)
|
||||
else: # location name
|
||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
|
||||
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||
|
||||
else:
|
||||
self.output(response)
|
||||
@@ -1943,14 +2018,65 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
|
||||
target_item, target_player, flags = ctx.locations[client.slot][location]
|
||||
if create_as_hint:
|
||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
|
||||
HintStatus.HINT_UNSPECIFIED))
|
||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
|
||||
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:
|
||||
ctx.save()
|
||||
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"]
|
||||
@@ -2184,6 +2310,19 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
self.output(f"Could not find player {player_name} to collect")
|
||||
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
|
||||
def _cmd_release(self, player_name: str) -> bool:
|
||||
"""Send out the remaining items from a player to their intended recipients."""
|
||||
@@ -2305,9 +2444,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
hints = []
|
||||
for item_name_from_group in self.ctx.item_name_groups[game][item]:
|
||||
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
|
||||
else: # item name or id
|
||||
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
|
||||
hints = collect_hints(self.ctx, team, slot, item)
|
||||
|
||||
if hints:
|
||||
self.ctx.notify_hints(team, hints)
|
||||
@@ -2341,17 +2480,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
|
||||
if usable:
|
||||
if isinstance(location, int):
|
||||
hints = collect_hint_location_id(self.ctx, team, slot, location,
|
||||
HintStatus.HINT_UNSPECIFIED)
|
||||
hints = collect_hint_location_id(self.ctx, team, slot, location)
|
||||
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
|
||||
hints = []
|
||||
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
|
||||
if loc_name_from_group in self.ctx.location_names_for_game(game):
|
||||
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group,
|
||||
HintStatus.HINT_UNSPECIFIED))
|
||||
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
|
||||
else:
|
||||
hints = collect_hint_location_name(self.ctx, team, slot, location,
|
||||
HintStatus.HINT_UNSPECIFIED)
|
||||
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
||||
if hints:
|
||||
self.ctx.notify_hints(team, hints)
|
||||
else:
|
||||
@@ -2379,6 +2515,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
elif value_type == str and option_name.endswith("password"):
|
||||
def value_type(input_text: str):
|
||||
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"):
|
||||
valid_values = {"goal", "enabled", "disabled"}
|
||||
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
|
||||
@@ -2466,6 +2607,13 @@ def parse_args() -> argparse.Namespace:
|
||||
goal: !collect can be used after 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='?',
|
||||
choices=['enabled', 'disabled', "goal"], help='''\
|
||||
Select !remaining Accessibility. (default: %(default)s)
|
||||
@@ -2531,7 +2679,7 @@ async def main(args: argparse.Namespace):
|
||||
|
||||
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.remaining_mode,
|
||||
args.countdown_mode, args.remaining_mode,
|
||||
args.auto_shutdown, args.compatibility, args.log_network)
|
||||
data_filename = args.multidata
|
||||
|
||||
@@ -2566,7 +2714,13 @@ async def main(args: argparse.Namespace):
|
||||
|
||||
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()
|
||||
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
|
||||
'No password' if not ctx.password else 'Password: %s' % ctx.password))
|
||||
|
||||
62
NetUtils.py
62
NetUtils.py
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
import typing
|
||||
import enum
|
||||
import warnings
|
||||
@@ -83,7 +84,7 @@ class NetworkSlot(typing.NamedTuple):
|
||||
name: str
|
||||
game: str
|
||||
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):
|
||||
@@ -106,6 +107,27 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
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(
|
||||
ensure_ascii=False,
|
||||
check_circular=False,
|
||||
@@ -152,6 +174,8 @@ decode = JSONDecoder(object_hook=_object_hook).decode
|
||||
|
||||
|
||||
class Endpoint:
|
||||
__slots__ = ("socket",)
|
||||
|
||||
socket: "ServerConnection"
|
||||
|
||||
def __init__(self, socket):
|
||||
@@ -450,6 +474,42 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
||||
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
|
||||
LocationStore = _LocationStore
|
||||
else:
|
||||
|
||||
@@ -277,6 +277,7 @@ async def n64_sync_task(ctx: OoTContext):
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
|
||||
|
||||
252
Options.py
252
Options.py
@@ -24,6 +24,39 @@ if typing.TYPE_CHECKING:
|
||||
import pathlib
|
||||
|
||||
|
||||
_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 triangular(lower: int, end: int, tri: float = 0.5) -> int:
|
||||
"""
|
||||
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))
|
||||
|
||||
|
||||
def random_weighted_range(text: str, range_start: int, range_end: int):
|
||||
if text == "random-low":
|
||||
return triangular(range_start, range_end, 0.0)
|
||||
elif text == "random-high":
|
||||
return triangular(range_start, range_end, 1.0)
|
||||
elif text == "random-middle":
|
||||
return triangular(range_start, range_end)
|
||||
elif text == "random":
|
||||
return random.randint(range_start, range_end)
|
||||
else:
|
||||
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
|
||||
f"Acceptable values are: {', '.join(_RANDOM_OPTS)}.")
|
||||
|
||||
|
||||
def roll_percentage(percentage: int | float) -> bool:
|
||||
"""Roll a percentage chance.
|
||||
percentage is expected to be in range [0, 100]"""
|
||||
@@ -417,10 +450,12 @@ class Toggle(NumericOption):
|
||||
def from_text(cls, text: str) -> Toggle:
|
||||
if text == "random":
|
||||
return cls(random.choice(list(cls.name_lookup)))
|
||||
elif text.lower() in {"off", "0", "false", "none", "null", "no"}:
|
||||
elif text.lower() in {"off", "0", "false", "none", "null", "no", "disabled"}:
|
||||
return cls(0)
|
||||
else:
|
||||
elif text.lower() in {"on", "1", "true", "yes", "enabled"}:
|
||||
return cls(1)
|
||||
else:
|
||||
raise OptionError(f"Option {cls.__name__} does not support a value of {text}")
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
@@ -494,14 +529,38 @@ class Choice(NumericOption):
|
||||
else:
|
||||
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__
|
||||
|
||||
|
||||
class TextChoice(Choice):
|
||||
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
|
||||
value: typing.Union[str, int]
|
||||
value: str | int
|
||||
|
||||
def __init__(self, value: typing.Union[str, int]):
|
||||
def __init__(self, value: str | int):
|
||||
assert isinstance(value, str) or isinstance(value, int), \
|
||||
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
|
||||
self.value = value
|
||||
@@ -522,7 +581,7 @@ class TextChoice(Choice):
|
||||
return cls(text)
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value: T) -> str:
|
||||
def get_option_name(cls, value: str | int) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return super().get_option_name(value)
|
||||
@@ -689,33 +748,39 @@ class Range(NumericOption):
|
||||
# these are the conditions where "true" and "false" make sense
|
||||
if text == "true":
|
||||
return cls.from_any(cls.default)
|
||||
else: # "false"
|
||||
# "false"
|
||||
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
|
||||
def weighted_range(cls, text) -> Range:
|
||||
if text == "random-low":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
|
||||
elif text == "random-high":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
|
||||
elif text == "random-middle":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end))
|
||||
elif text.startswith("random-range-"):
|
||||
if text.startswith("random-range-"):
|
||||
return cls.custom_range(text)
|
||||
elif text == "random":
|
||||
return cls(random.randint(cls.range_start, cls.range_end))
|
||||
else:
|
||||
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
|
||||
f"Acceptable values are: random, random-high, random-middle, random-low, "
|
||||
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
|
||||
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||
return cls(random_weighted_range(text, cls.range_start, cls.range_end))
|
||||
|
||||
@classmethod
|
||||
def custom_range(cls, text) -> Range:
|
||||
textsplit = text.split("-")
|
||||
try:
|
||||
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
|
||||
random_range = [int(textsplit[-2]), int(textsplit[-1])]
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
|
||||
random_range.sort()
|
||||
@@ -723,14 +788,9 @@ class Range(NumericOption):
|
||||
raise Exception(
|
||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||
if text.startswith("random-range-low"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
|
||||
elif text.startswith("random-range-middle"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1]))
|
||||
elif text.startswith("random-range-high"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
|
||||
else:
|
||||
return cls(random.randint(random_range[0], random_range[1]))
|
||||
if textsplit[2] in ("low", "middle", "high"):
|
||||
return cls(random_weighted_range(f"{textsplit[0]}-{textsplit[2]}", *random_range))
|
||||
return cls(random_weighted_range("random", *random_range))
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any) -> Range:
|
||||
@@ -745,18 +805,6 @@ class Range(NumericOption):
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
@staticmethod
|
||||
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
|
||||
"""
|
||||
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):
|
||||
special_range_names: typing.Dict[str, int] = {}
|
||||
@@ -861,17 +909,18 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
||||
else:
|
||||
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
|
||||
|
||||
def get_option_name(self, value):
|
||||
@classmethod
|
||||
def get_option_name(cls, value):
|
||||
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
||||
|
||||
def __getitem__(self, item: str) -> typing.Any:
|
||||
return self.value.__getitem__(item)
|
||||
return self.value[item]
|
||||
|
||||
def __iter__(self) -> typing.Iterator[str]:
|
||||
return self.value.__iter__()
|
||||
return iter(self.value)
|
||||
|
||||
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:
|
||||
@@ -941,7 +990,8 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self, value):
|
||||
@classmethod
|
||||
def get_option_name(cls, value):
|
||||
return ", ".join(map(str, value))
|
||||
|
||||
def __contains__(self, item):
|
||||
@@ -951,13 +1001,19 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||
default = frozenset()
|
||||
supports_weighting = False
|
||||
random_str: str | None
|
||||
|
||||
def __init__(self, value: typing.Iterable[str]):
|
||||
def __init__(self, value: typing.Iterable[str], random_str: str | None = None):
|
||||
self.value = set(deepcopy(value))
|
||||
self.random_str = random_str
|
||||
super(OptionSet, self).__init__()
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str):
|
||||
check_text = text.lower().split(",")
|
||||
if ((cls.valid_keys or cls.verify_item_name or cls.verify_location_name)
|
||||
and len(check_text) == 1 and check_text[0].startswith("random")):
|
||||
return cls((), check_text[0])
|
||||
return cls([option.strip() for option in text.split(",")])
|
||||
|
||||
@classmethod
|
||||
@@ -966,7 +1022,37 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self, value):
|
||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
|
||||
if self.random_str and not self.value:
|
||||
choice_list = sorted(self.valid_keys)
|
||||
if self.verify_item_name:
|
||||
choice_list.extend(sorted(world.item_names))
|
||||
if self.verify_location_name:
|
||||
choice_list.extend(sorted(world.location_names))
|
||||
if self.random_str.startswith("random-range-"):
|
||||
textsplit = self.random_str.split("-")
|
||||
try:
|
||||
random_range = [int(textsplit[-2]), int(textsplit[-1])]
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid random range {self.random_str} for option {self.__class__.__name__} "
|
||||
f"for player {player_name}")
|
||||
random_range.sort()
|
||||
if random_range[0] < 0 or random_range[1] > len(choice_list):
|
||||
raise Exception(
|
||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||
f"0-{len(choice_list)} for option {self.__class__.__name__} for player {player_name}")
|
||||
if textsplit[2] in ("low", "middle", "high"):
|
||||
choice_count = random_weighted_range(f"{textsplit[0]}-{textsplit[2]}",
|
||||
random_range[0], random_range[1])
|
||||
else:
|
||||
choice_count = random_weighted_range("random", random_range[0], random_range[1])
|
||||
else:
|
||||
choice_count = random_weighted_range(self.random_str, 0, len(choice_list))
|
||||
self.value = set(random.sample(choice_list, k=choice_count))
|
||||
super(Option, self).verify(world, player_name, plando_options)
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value):
|
||||
return ", ".join(sorted(value))
|
||||
|
||||
def __contains__(self, item):
|
||||
@@ -994,6 +1080,8 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
supports_weighting = False
|
||||
display_name = "Plando Texts"
|
||||
|
||||
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
|
||||
|
||||
def __init__(self, value: typing.Iterable[PlandoText]) -> None:
|
||||
self.value = list(deepcopy(value))
|
||||
super().__init__()
|
||||
@@ -1067,10 +1155,10 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
||||
yield from self.value
|
||||
|
||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
|
||||
return self.value.__getitem__(index)
|
||||
return self.value[index]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.value.__len__()
|
||||
return len(self.value)
|
||||
|
||||
|
||||
class ConnectionsMeta(AssembleOptions):
|
||||
@@ -1094,7 +1182,7 @@ class PlandoConnection(typing.NamedTuple):
|
||||
|
||||
entrance: 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
|
||||
|
||||
|
||||
@@ -1120,6 +1208,8 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
||||
entrances: typing.ClassVar[typing.AbstractSet[str]]
|
||||
exits: typing.ClassVar[typing.AbstractSet[str]]
|
||||
|
||||
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
|
||||
|
||||
duplicate_exits: bool = False
|
||||
"""Whether or not exits should be allowed to be duplicate."""
|
||||
|
||||
@@ -1217,7 +1307,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
||||
connection.exit) for connection in value])
|
||||
|
||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
|
||||
return self.value.__getitem__(index)
|
||||
return self.value[index]
|
||||
|
||||
def __iter__(self) -> typing.Iterator[PlandoConnection]:
|
||||
yield from self.value
|
||||
@@ -1315,6 +1405,7 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
||||
will be returned as a sorted list.
|
||||
"""
|
||||
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 = {}
|
||||
for option_name in option_names:
|
||||
if option_name not in type(self).type_hints:
|
||||
@@ -1355,7 +1446,7 @@ class NonLocalItems(ItemSet):
|
||||
|
||||
|
||||
class StartInventory(ItemDict):
|
||||
"""Start with these items."""
|
||||
"""Start with the specified amount of these items. Example: "Bomb: 1" """
|
||||
verify_item_name = True
|
||||
display_name = "Start Inventory"
|
||||
rich_text_doc = True
|
||||
@@ -1363,7 +1454,7 @@ class StartInventory(ItemDict):
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
@@ -1410,6 +1501,7 @@ class DeathLink(Toggle):
|
||||
class ItemLinks(OptionList):
|
||||
"""Share part of your item pool with other players."""
|
||||
display_name = "Item Links"
|
||||
visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
|
||||
rich_text_doc = True
|
||||
default = []
|
||||
schema = Schema([
|
||||
@@ -1421,6 +1513,7 @@ class ItemLinks(OptionList):
|
||||
Optional("local_items"): [And(str, len)],
|
||||
Optional("non_local_items"): [And(str, len)],
|
||||
Optional("link_replacement"): Or(None, bool),
|
||||
Optional("skip_if_solo"): Or(None, bool),
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1448,8 +1541,10 @@ class ItemLinks(OptionList):
|
||||
super(ItemLinks, self).verify(world, player_name, plando_options)
|
||||
existing_links = set()
|
||||
for link in self.value:
|
||||
link["name"] = link["name"].strip()[:16].strip()
|
||||
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"])
|
||||
|
||||
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
|
||||
@@ -1491,6 +1586,7 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
|
||||
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))
|
||||
@@ -1643,7 +1739,7 @@ class OptionGroup(typing.NamedTuple):
|
||||
|
||||
|
||||
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.
|
||||
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
|
||||
@@ -1687,8 +1783,10 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
from Utils import local_path, __version__
|
||||
|
||||
full_path: str
|
||||
preset_folder = os.path.join(target_folder, "Presets")
|
||||
|
||||
os.makedirs(target_folder, exist_ok=True)
|
||||
os.makedirs(preset_folder, exist_ok=True)
|
||||
|
||||
# clean out old
|
||||
for file in os.listdir(target_folder):
|
||||
@@ -1696,18 +1794,30 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
|
||||
os.unlink(full_path)
|
||||
|
||||
def dictify_range(option: Range):
|
||||
data = {option.default: 50}
|
||||
for sub_option in ["random", "random-low", "random-high"]:
|
||||
if sub_option != option.default:
|
||||
data[sub_option] = 0
|
||||
for file in os.listdir(preset_folder):
|
||||
full_path = os.path.join(preset_folder, file)
|
||||
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
|
||||
os.unlink(full_path)
|
||||
|
||||
notes = {}
|
||||
def dictify_range(option: Range, option_val: int | str):
|
||||
data = {option_val: 50}
|
||||
for sub_option in ["random", "random-low", "random-high",
|
||||
f"random-range-{option.range_start}-{option.range_end}"]:
|
||||
if sub_option != option_val:
|
||||
data[sub_option] = 0
|
||||
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():
|
||||
notes[name] = f"equivalent to {number}"
|
||||
if number in data:
|
||||
data[name] = data[number]
|
||||
del data[number]
|
||||
elif name in data:
|
||||
pass
|
||||
else:
|
||||
data[name] = 0
|
||||
|
||||
@@ -1723,16 +1833,26 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
||||
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden or generate_hidden:
|
||||
option_groups = get_option_groups(world)
|
||||
presets = world.web.options_presets.copy()
|
||||
presets.update({"": {}})
|
||||
|
||||
option_groups = get_option_groups(world)
|
||||
for name, preset in presets.items():
|
||||
res = template.render(
|
||||
option_groups=option_groups,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
||||
__version__=__version__,
|
||||
game=game_name,
|
||||
world_version=world.world_version.as_simple_string(),
|
||||
yaml_dump=yaml_dump_scalar,
|
||||
dictify_range=dictify_range,
|
||||
cleandoc=cleandoc,
|
||||
preset_name=name,
|
||||
preset=preset,
|
||||
)
|
||||
|
||||
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||
preset_name = f" - {name}" if name else ""
|
||||
with open(os.path.join(preset_folder if name else target_folder,
|
||||
get_file_safe_name(game_name + preset_name) + ".yaml"),
|
||||
"w", encoding="utf-8-sig") as f:
|
||||
f.write(res)
|
||||
|
||||
|
||||
|
||||
696
OptionsCreator.py
Normal file
696
OptionsCreator.py
Normal file
@@ -0,0 +1,696 @@
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
|
||||
from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel,
|
||||
ToggleButton, MarkupDropdown, ResizableTextField)
|
||||
from kivy.clock import Clock
|
||||
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__()
|
||||
|
||||
@staticmethod
|
||||
def show_result_snack(text: str) -> None:
|
||||
MDSnackbar(MDSnackbarText(text=text), y=dp(24), pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
|
||||
|
||||
def on_export_result(self, text: str | None) -> None:
|
||||
self.container.disabled = False
|
||||
if text is not None:
|
||||
Clock.schedule_once(lambda _: self.show_result_snack(text), 0)
|
||||
|
||||
def export_options_background(self, options: dict[str, typing.Any]) -> None:
|
||||
try:
|
||||
file_name = Utils.save_filename("Export Options File As...", [("YAML", [".yaml"])],
|
||||
Utils.get_file_safe_name(f"{self.name_input.text}.yaml"))
|
||||
except Exception:
|
||||
self.on_export_result("Could not open dialog. Already open?")
|
||||
raise
|
||||
|
||||
if not file_name:
|
||||
self.on_export_result(None) # No file selected. No need to show a message for this.
|
||||
return
|
||||
|
||||
try:
|
||||
with open(file_name, 'w') as f:
|
||||
f.write(Utils.dump(options, sort_keys=False))
|
||||
f.close()
|
||||
self.on_export_result("File saved successfully.")
|
||||
except Exception:
|
||||
self.on_export_result("Could not save file.")
|
||||
raise
|
||||
|
||||
def export_options(self, button: Widget) -> None:
|
||||
if 0 < len(self.name_input.text) < 17 and self.current_game:
|
||||
import threading
|
||||
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()}
|
||||
}
|
||||
threading.Thread(target=self.export_options_background, args=(options,), daemon=True).start()
|
||||
self.container.disabled = True
|
||||
elif not self.name_input.text:
|
||||
self.show_result_snack("Name must not be empty.")
|
||||
elif not self.current_game:
|
||||
self.show_result_snack("You must select a game to play.")
|
||||
else:
|
||||
self.show_result_snack("Name cannot be longer than 16 characters.")
|
||||
|
||||
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():
|
||||
self.options[name] = int(self.options[name])
|
||||
elif self.options[name] in ("True", "False"):
|
||||
self.options[name] = self.options[name] == "True"
|
||||
|
||||
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 cls.hidden:
|
||||
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()
|
||||
@@ -14,14 +14,12 @@ Currently, the following games are supported:
|
||||
* Super Metroid
|
||||
* Secret of Evermore
|
||||
* Final Fantasy
|
||||
* Rogue Legacy
|
||||
* VVVVVV
|
||||
* Raft
|
||||
* Super Mario 64
|
||||
* Meritous
|
||||
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
||||
* ChecksFinder
|
||||
* ArchipIDLE
|
||||
* Hollow Knight
|
||||
* The Witness
|
||||
* Sonic Adventure 2: Battle
|
||||
@@ -41,7 +39,6 @@ Currently, the following games are supported:
|
||||
* The Messenger
|
||||
* Kingdom Hearts 2
|
||||
* The Legend of Zelda: Link's Awakening DX
|
||||
* Clique
|
||||
* Adventure
|
||||
* DLC Quest
|
||||
* Noita
|
||||
@@ -82,6 +79,12 @@ Currently, the following games are supported:
|
||||
* Jak and Daxter: The Precursor Legacy
|
||||
* Super Mario Land 2: 6 Golden Coins
|
||||
* shapez
|
||||
* Paint
|
||||
* Celeste (Open World)
|
||||
* Choo-Choo Charles
|
||||
* APQuest
|
||||
* Satisfactory
|
||||
* EarthBound
|
||||
|
||||
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
|
||||
|
||||
@@ -18,6 +18,7 @@ from json import loads, dumps
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
|
||||
import Utils
|
||||
import settings
|
||||
from Utils import async_start
|
||||
from MultiServer import mark_raw
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -285,7 +286,7 @@ class SNESState(enum.IntEnum):
|
||||
|
||||
|
||||
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):
|
||||
sni_path = Utils.local_path(sni_path)
|
||||
@@ -668,8 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None:
|
||||
|
||||
|
||||
async def run_game(romfile: str) -> None:
|
||||
auto_start = typing.cast(typing.Union[bool, str],
|
||||
Utils.get_settings()["sni_options"].get("snes_rom_start", True))
|
||||
auto_start = settings.get_settings().sni_options.snes_rom_start
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
|
||||
@@ -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()
|
||||
332
Utils.py
332
Utils.py
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import typing
|
||||
import builtins
|
||||
@@ -21,6 +22,7 @@ from settings import Settings, get_settings
|
||||
from time import sleep
|
||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
|
||||
from yaml import load, load_all, dump
|
||||
from pathspec import PathSpec, GitIgnoreSpec
|
||||
|
||||
try:
|
||||
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
|
||||
@@ -35,7 +37,7 @@ if typing.TYPE_CHECKING:
|
||||
|
||||
|
||||
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):
|
||||
@@ -47,7 +49,7 @@ class Version(typing.NamedTuple):
|
||||
return ".".join(str(item) for item in self)
|
||||
|
||||
|
||||
__version__ = "0.6.2"
|
||||
__version__ = "0.6.7"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
@@ -313,20 +315,18 @@ def get_public_ipv6() -> str:
|
||||
return ip
|
||||
|
||||
|
||||
OptionsType = Settings # TODO: remove when removing get_options
|
||||
|
||||
|
||||
def get_options() -> Settings:
|
||||
# TODO: switch to Utils.deprecate after 0.4.4
|
||||
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
|
||||
deprecate("Utils.get_options() is deprecated. Use the settings API instead.")
|
||||
return get_settings()
|
||||
|
||||
|
||||
def persistent_store(category: str, key: str, value: typing.Any):
|
||||
path = user_path("_persistent_storage.yaml")
|
||||
def persistent_store(category: str, key: str, value: typing.Any, force_store: bool = False):
|
||||
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[key] = value
|
||||
path = user_path("_persistent_storage.yaml")
|
||||
with open(path, "wt") as f:
|
||||
f.write(dump(storage, Dumper=Dumper))
|
||||
|
||||
@@ -388,6 +388,14 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
|
||||
logging.debug(f"Could not store data package: {e}")
|
||||
|
||||
|
||||
def read_apignore(filename: str | pathlib.Path) -> PathSpec | None:
|
||||
try:
|
||||
with open(filename) as ignore_file:
|
||||
return GitIgnoreSpec.from_lines(ignore_file)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def get_default_adjuster_settings(game_name: str) -> Namespace:
|
||||
import LttPAdjuster
|
||||
adjuster_settings = Namespace()
|
||||
@@ -413,13 +421,26 @@ def get_adjuster_settings(game_name: str) -> Namespace:
|
||||
|
||||
@cache_argsless
|
||||
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:
|
||||
return uuid
|
||||
|
||||
import uuid
|
||||
uuid = uuid.getnode()
|
||||
persistent_store("client", "uuid", uuid)
|
||||
from uuid import uuid4
|
||||
uuid = str(uuid4())
|
||||
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
|
||||
|
||||
|
||||
@@ -442,6 +463,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
if module == "builtins" and name in safe_builtins:
|
||||
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
|
||||
@@ -461,7 +483,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
mod = importlib.import_module(module)
|
||||
obj = getattr(mod, name)
|
||||
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
|
||||
self.options_module.PlandoText)):
|
||||
self.options_module.PlandoItem, self.options_module.PlandoText)):
|
||||
return obj
|
||||
# Forbid everything else.
|
||||
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||
@@ -472,6 +494,18 @@ def restricted_loads(s: bytes) -> Any:
|
||||
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:
|
||||
"""
|
||||
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
|
||||
@@ -692,13 +726,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]:
|
||||
"""
|
||||
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:
|
||||
for question in ("Didn't find something that closely matches",
|
||||
"Too many close matches"):
|
||||
if text.startswith(question):
|
||||
name = get_text_between(text, "did you mean '",
|
||||
"'? (")
|
||||
return f"!{command} {name}"
|
||||
return f"{command} {name}"
|
||||
elif text.startswith("Missing: "):
|
||||
return text.replace("Missing: ", "!hint_location ")
|
||||
return None
|
||||
@@ -717,6 +760,11 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *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:
|
||||
@@ -763,8 +811,62 @@ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typin
|
||||
except tkinter.TclError:
|
||||
return None # GUI not available. None is the same as a user clicking "cancel"
|
||||
root.withdraw()
|
||||
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
||||
initialfile=suggest or None)
|
||||
try:
|
||||
return tkinter.filedialog.askopenfilename(
|
||||
title=title,
|
||||
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
||||
initialfile=suggest or None,
|
||||
)
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
|
||||
def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
||||
-> typing.Optional[str]:
|
||||
logging.info(f"Opening file save dialog for {title}.")
|
||||
|
||||
if is_linux:
|
||||
# prefer native dialog
|
||||
from shutil import which
|
||||
kdialog = which("kdialog")
|
||||
if kdialog:
|
||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||
return _run_for_stdout(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_for_stdout(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()
|
||||
try:
|
||||
return tkinter.filedialog.asksaveasfilename(
|
||||
title=title,
|
||||
filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
||||
initialfile=suggest or None,
|
||||
)
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
|
||||
def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
|
||||
@@ -812,6 +914,13 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
||||
|
||||
|
||||
def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
if not gui_enabled:
|
||||
if error:
|
||||
logging.error(f"{title}: {text}")
|
||||
else:
|
||||
logging.info(f"{title}: {text}")
|
||||
return
|
||||
|
||||
if is_kivy_running():
|
||||
from kvui import MessageBox
|
||||
MessageBox(title, text, error).open()
|
||||
@@ -847,6 +956,9 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
root.update()
|
||||
|
||||
|
||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
||||
"""Checks if the user wanted no GUI mode and has a terminal to use it with."""
|
||||
|
||||
def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))):
|
||||
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
|
||||
def sorter(element: Union[str, Dict[str, Any]]) -> str:
|
||||
@@ -877,7 +989,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,
|
||||
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:
|
||||
# ```
|
||||
# Important: Save a reference to the result of [asyncio.create_task],
|
||||
@@ -914,15 +1026,15 @@ class DeprecateDict(dict):
|
||||
|
||||
|
||||
def _extend_freeze_support() -> None:
|
||||
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
|
||||
# upstream issue: https://github.com/python/cpython/issues/76327
|
||||
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first."""
|
||||
# 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
|
||||
import multiprocessing
|
||||
import multiprocessing.spawn
|
||||
|
||||
def _freeze_support() -> None:
|
||||
"""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
|
||||
multiprocessing.process.ORIGINAL_DIR = None
|
||||
@@ -930,8 +1042,7 @@ def _extend_freeze_support() -> None:
|
||||
# Handle the first process that MP will create
|
||||
if (
|
||||
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
|
||||
'from multiprocessing.semaphore_tracker import main', # Py<3.8
|
||||
'from multiprocessing.resource_tracker import main', # Py>=3.8
|
||||
'from multiprocessing.resource_tracker import main',
|
||||
'from multiprocessing.forkserver import main'
|
||||
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
|
||||
):
|
||||
@@ -950,20 +1061,35 @@ def _extend_freeze_support() -> None:
|
||||
multiprocessing.spawn.spawn_main(**kwargs)
|
||||
sys.exit()
|
||||
|
||||
if not is_windows and is_frozen():
|
||||
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
|
||||
def _noop() -> None:
|
||||
pass
|
||||
|
||||
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
|
||||
|
||||
|
||||
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
|
||||
_extend_freeze_support()
|
||||
|
||||
deprecate("Use multiprocessing.freeze_support() instead")
|
||||
multiprocessing.freeze_support()
|
||||
|
||||
|
||||
def visualize_regions(root_region: Region, file_name: str, *,
|
||||
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
||||
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
|
||||
_extend_freeze_support()
|
||||
|
||||
|
||||
def visualize_regions(
|
||||
root_region: Region,
|
||||
file_name: str,
|
||||
*,
|
||||
show_entrance_names: bool = False,
|
||||
show_locations: bool = True,
|
||||
show_other_regions: bool = True,
|
||||
linetype_ortho: bool = True,
|
||||
regions_to_highlight: set[Region] | None = None,
|
||||
entrance_highlighting: dict[int, int] | None = None,
|
||||
detail_other_regions: bool = False,
|
||||
auto_assign_colors: bool = False) -> None:
|
||||
"""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.)
|
||||
@@ -980,6 +1106,13 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
||||
: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 regions_to_highlight: Regions that will be highlighted in green if they are reachable.
|
||||
:param entrance_highlighting: a mapping from your world's entrance randomization groups to RGB values, used to color
|
||||
your entrances
|
||||
:param detail_other_regions: (default False) If enabled, will fully visualize regions that aren't reachable
|
||||
from root_region.
|
||||
:param auto_assign_colors: (default False) If enabled, will automatically assign random colors to entrances of the
|
||||
same randomization group. Uses entrance_highlighting first, and only picks random colors for entrance groups
|
||||
not found in the passed-in map
|
||||
|
||||
Example usage in World code:
|
||||
from Utils import visualize_regions
|
||||
@@ -1005,6 +1138,34 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
||||
regions: typing.Deque[Region] = deque((root_region,))
|
||||
multiworld: MultiWorld = root_region.multiworld
|
||||
|
||||
colors_used: set[int] = set()
|
||||
if entrance_highlighting:
|
||||
for color in entrance_highlighting.values():
|
||||
# filter the colors to their most-significant bits to avoid too similar colors
|
||||
colors_used.add(color & 0xF0F0F0)
|
||||
else:
|
||||
# assign an empty dict to not crash later
|
||||
# the parameter is optional for ease of use when you don't care about colors
|
||||
entrance_highlighting = {}
|
||||
|
||||
def select_color(group: int) -> int:
|
||||
# specifically spacing color indexes by three different prime numbers (3, 5, 7) for the RGB components to avoid
|
||||
# obvious cyclical color patterns
|
||||
COLOR_INDEX_SPACING: int = 0x357
|
||||
new_color_index: int = (group * COLOR_INDEX_SPACING) % 0x1000
|
||||
new_color = ((new_color_index & 0xF00) << 12) + \
|
||||
((new_color_index & 0xF0) << 8) + \
|
||||
((new_color_index & 0xF) << 4)
|
||||
while new_color in colors_used:
|
||||
# while this is technically unbounded, expected collisions are low. There are 4095 possible colors
|
||||
# and worlds are unlikely to get to anywhere close to that many entrance groups
|
||||
# intentionally not using multiworld.random to not affect output when debugging with this tool
|
||||
new_color_index += COLOR_INDEX_SPACING
|
||||
new_color = ((new_color_index & 0xF00) << 12) + \
|
||||
((new_color_index & 0xF0) << 8) + \
|
||||
((new_color_index & 0xF) << 4)
|
||||
return new_color
|
||||
|
||||
def fmt(obj: Union[Entrance, Item, Location, Region]) -> str:
|
||||
name = obj.name
|
||||
if isinstance(obj, Item):
|
||||
@@ -1024,18 +1185,28 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
||||
|
||||
def visualize_exits(region: Region) -> None:
|
||||
for exit_ in region.exits:
|
||||
color_code: str = ""
|
||||
if exit_.randomization_group in entrance_highlighting:
|
||||
color_code = f" #{entrance_highlighting[exit_.randomization_group]:0>6X}"
|
||||
if exit_.connected_region:
|
||||
if show_entrance_names:
|
||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"")
|
||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"{color_code}")
|
||||
else:
|
||||
try:
|
||||
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"")
|
||||
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"")
|
||||
uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"{color_code}")
|
||||
uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"{color_code}")
|
||||
except ValueError:
|
||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"")
|
||||
uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"{color_code}")
|
||||
else:
|
||||
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"")
|
||||
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"")
|
||||
uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\" {color_code}")
|
||||
uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"{color_code}")
|
||||
for entrance in region.entrances:
|
||||
color_code: str = ""
|
||||
if entrance.randomization_group in entrance_highlighting:
|
||||
color_code = f" #{entrance_highlighting[entrance.randomization_group]:0>6X}"
|
||||
if not entrance.parent_region:
|
||||
uml.append(f"circle \"unconnected entrance:\\n{fmt(entrance)}\"{color_code}")
|
||||
uml.append(f"\"unconnected entrance:\\n{fmt(entrance)}\" --> \"{fmt(region)}\"{color_code}")
|
||||
|
||||
def visualize_locations(region: Region) -> None:
|
||||
any_lock = any(location.locked for location in region.locations)
|
||||
@@ -1056,9 +1227,27 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
||||
if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]:
|
||||
uml.append("package \"other regions\" <<Cloud>> {")
|
||||
for region in other_regions:
|
||||
if detail_other_regions:
|
||||
visualize_region(region)
|
||||
else:
|
||||
uml.append(f"class \"{fmt(region)}\"")
|
||||
uml.append("}")
|
||||
|
||||
if auto_assign_colors:
|
||||
all_entrances: list[Entrance] = []
|
||||
for region in multiworld.get_regions(root_region.player):
|
||||
all_entrances.extend(region.entrances)
|
||||
all_entrances.extend(region.exits)
|
||||
all_groups: list[int] = sorted(set([entrance.randomization_group for entrance in all_entrances]))
|
||||
for group in all_groups:
|
||||
if group not in entrance_highlighting:
|
||||
if len(colors_used) >= 0x1000:
|
||||
# on the off chance someone makes 4096 different entrance groups, don't cycle forever
|
||||
break
|
||||
new_color: int = select_color(group)
|
||||
entrance_highlighting[group] = new_color
|
||||
colors_used.add(new_color)
|
||||
|
||||
uml.append("@startuml")
|
||||
uml.append("hide circle")
|
||||
uml.append("hide empty members")
|
||||
@@ -1069,7 +1258,7 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
||||
seen.add(current_region)
|
||||
visualize_region(current_region)
|
||||
regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region)
|
||||
if show_other_regions:
|
||||
if show_other_regions or detail_other_regions:
|
||||
visualize_other_regions()
|
||||
uml.append("@enduml")
|
||||
|
||||
@@ -1096,3 +1285,72 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
|
||||
if isinstance(obj, str):
|
||||
return False
|
||||
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
|
||||
|
||||
|
||||
def get_full_typename(t: type) -> str:
|
||||
"""Returns the full qualified name of a type, including its module (if not builtins)."""
|
||||
module = t.__module__
|
||||
if module and module != "builtins":
|
||||
return f"{module}.{t.__qualname__}"
|
||||
return t.__qualname__
|
||||
|
||||
|
||||
def get_all_causes(ex: Exception) -> str:
|
||||
"""Return a string describing the recursive causes of this exception.
|
||||
|
||||
:param ex: The exception to be described.
|
||||
:return A multiline string starting with the initial exception on the first line and each resulting exception
|
||||
on subsequent lines with progressive indentation.
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
Exception: Invalid value 'bad'.
|
||||
Which caused: Options.OptionError: Error generating option
|
||||
Which caused: ValueError: File bad.yaml is invalid.
|
||||
```
|
||||
"""
|
||||
cause = ex
|
||||
causes = [f"{get_full_typename(type(ex))}: {ex}"]
|
||||
while cause := cause.__cause__:
|
||||
causes.append(f"{get_full_typename(type(cause))}: {cause}")
|
||||
top = causes[-1]
|
||||
others = "".join(f"\n{' ' * (i + 1)}Which caused: {c}" for i, c in enumerate(reversed(causes[:-1])))
|
||||
return f"{top}{others}"
|
||||
|
||||
60
WebHost.py
60
WebHost.py
@@ -20,7 +20,8 @@ if typing.TYPE_CHECKING:
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__)
|
||||
settings.no_gui = True
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
if not os.path.exists(configpath):
|
||||
# fall back to config.yaml in user_path if config does not exist in cwd to match settings.py
|
||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||
|
||||
|
||||
@@ -54,16 +55,15 @@ def get_app() -> "Flask":
|
||||
return app
|
||||
|
||||
|
||||
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
||||
import json
|
||||
def copy_tutorials_files_to_static() -> None:
|
||||
import shutil
|
||||
import zipfile
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
zfile: zipfile.ZipInfo
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
worlds = {}
|
||||
data = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
||||
worlds[game] = world
|
||||
@@ -72,7 +72,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
||||
shutil.rmtree(base_target_path, ignore_errors=True)
|
||||
for game, world in worlds.items():
|
||||
# copy files from world's docs folder to the generated folder
|
||||
target_path = os.path.join(base_target_path, get_file_safe_name(game))
|
||||
target_path = os.path.join(base_target_path, secure_filename(game))
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
|
||||
if world.zip_path:
|
||||
@@ -85,45 +85,14 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
||||
for zfile in zf.infolist():
|
||||
if not zfile.is_dir() and "/docs/" in 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:
|
||||
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
||||
files = os.listdir(source_path)
|
||||
for file in files:
|
||||
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
|
||||
|
||||
# build a json tutorial dict per game
|
||||
game_data = {'gameTitle': game, 'tutorials': []}
|
||||
for tutorial in world.web.tutorials:
|
||||
# build dict for the json file
|
||||
current_tutorial = {
|
||||
'name': tutorial.tutorial_name,
|
||||
'description': tutorial.description,
|
||||
'files': [{
|
||||
'language': tutorial.language,
|
||||
'filename': game + '/' + tutorial.file_name,
|
||||
'link': f'{game}/{tutorial.link}',
|
||||
'authors': tutorial.authors
|
||||
}]
|
||||
}
|
||||
|
||||
# check if the name of the current guide exists already
|
||||
for guide in game_data['tutorials']:
|
||||
if guide and tutorial.tutorial_name == guide['name']:
|
||||
guide['files'].append(current_tutorial['files'][0])
|
||||
break
|
||||
else:
|
||||
game_data['tutorials'].append(current_tutorial)
|
||||
|
||||
data.append(game_data)
|
||||
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
|
||||
generic_data = {}
|
||||
for games in data:
|
||||
if 'Archipelago' in games['gameTitle']:
|
||||
generic_data = data.pop(data.index(games))
|
||||
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
|
||||
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
||||
return sorted_data
|
||||
shutil.copyfile(Utils.local_path(source_path, file),
|
||||
Utils.local_path(target_path, secure_filename(file)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -131,18 +100,25 @@ if __name__ == "__main__":
|
||||
multiprocessing.set_start_method('spawn')
|
||||
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.options import create as create_options_files
|
||||
|
||||
try:
|
||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
update_sprites_lttp()
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.warning("Could not update LttP sprites.")
|
||||
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_ordered_tutorials_file()
|
||||
copy_tutorials_files_to_static()
|
||||
if app.config["SELFLAUNCH"]:
|
||||
autohost(app.config)
|
||||
if app.config["SELFGEN"]:
|
||||
|
||||
@@ -1,46 +1,20 @@
|
||||
# WebHost
|
||||
|
||||
## Asset License
|
||||
|
||||
The image files used in the page design were specifically designed for archipelago.gg and are **not** covered by the top
|
||||
level LICENSE.
|
||||
See individual LICENSE files in `./static/static/**`.
|
||||
|
||||
You are only allowed to use them for personal use, testing and development.
|
||||
If the site is reachable over the internet, have a robots.txt in place (see `ASSET_RIGHTS` in `config.yaml`)
|
||||
and do not promote it publicly. Alternatively replace or remove the assets.
|
||||
|
||||
## Contribution Guidelines
|
||||
**Thank you for your interest in contributing to the Archipelago website!**
|
||||
Much of the content on the website is generated automatically, but there are some things
|
||||
that need a personal touch. For those things, we rely on contributions from both the core
|
||||
team and the community. The current primary maintainer of the website is Farrak Kilhn.
|
||||
He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`.
|
||||
|
||||
### Small Changes
|
||||
Little changes like adding a button or a couple new select elements are perfectly fine.
|
||||
Tweaks to style specific to a PR's content are also probably not a problem. For example, if
|
||||
you build a new page which needs two side by side tables, and you need to write a CSS file
|
||||
specific to your page, that is perfectly reasonable.
|
||||
Pages should preferably be rendered on the server side with Jinja. Features should work with noscript if feasible.
|
||||
Design changes have to fit the overall design.
|
||||
|
||||
### Content Additions
|
||||
Once you develop a new feature or add new content the website, make a pull request. It will
|
||||
be reviewed by the community and there will probably be some discussion around it. Depending
|
||||
on the size of the feature, and if new styles are required, there may be an additional step
|
||||
before the PR is accepted wherein Farrak works with the designer to implement styles.
|
||||
Introduction of JS dependencies should first be discussed on Discord or in a draft PR.
|
||||
|
||||
### Restrictions on Style Changes
|
||||
A professional designer is paid to develop the styles and assets for the Archipelago website.
|
||||
In an effort to maintain a consistent look and feel, pull requests which *exclusively*
|
||||
change site styles are rejected. Please note this applies to code which changes the overall
|
||||
look and feel of the site, not to small tweaks to CSS for your custom page. The intention
|
||||
behind these restrictions is to maintain a curated feel for the design of the site. If
|
||||
any PR affects the overall feel of the site but includes additive changes, there will
|
||||
likely be a conversation about how to implement those changes without compromising the
|
||||
curated site style. It is therefore worth noting there are a couple files which, if
|
||||
changed in your pull request, will cause it to draw additional scrutiny.
|
||||
|
||||
These closely guarded files are:
|
||||
- `globalStyles.css`
|
||||
- `islandFooter.css`
|
||||
- `landing.css`
|
||||
- `markdown.css`
|
||||
- `tooltip.css`
|
||||
|
||||
### Site Themes
|
||||
There are several themes available for game pages. It is possible to request a new theme in
|
||||
the `#art-and-design` channel on Discord. Because themes are created by the designer, they
|
||||
are not free, and take some time to create. Farrak works closely with the designer to implement
|
||||
these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year
|
||||
are added. If a proposed theme seems like a cool idea and the community likes it, there is a
|
||||
good chance it will become a reality.
|
||||
See also [docs/style.md](/docs/style.md) for the style guide.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import base64
|
||||
import os
|
||||
import socket
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from flask import Flask
|
||||
@@ -22,6 +23,17 @@ app.jinja_env.filters['any'] = any
|
||||
app.jinja_env.filters['all'] = all
|
||||
app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name
|
||||
|
||||
# overwrites of flask default config
|
||||
app.config["DEBUG"] = False
|
||||
app.config["PORT"] = 80
|
||||
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
|
||||
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 # 64 megabyte limit
|
||||
# if you want to deploy, make sure you have a non-guessable secret key
|
||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||
app.config["SESSION_PERMANENT"] = True
|
||||
app.config["MAX_FORM_MEMORY_SIZE"] = 2 * 1024 * 1024 # 2 MB, needed for large option pages such as SC2
|
||||
|
||||
# custom config
|
||||
app.config["SELFHOST"] = True # application process is in charge of running the websites
|
||||
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
||||
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
|
||||
@@ -29,19 +41,12 @@ app.config["SELFLAUNCH"] = True # application process is in charge of launching
|
||||
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
|
||||
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
||||
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
||||
app.config["DEBUG"] = False
|
||||
app.config["PORT"] = 80
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
||||
# if you want to deploy, make sure you have a non-guessable secret key
|
||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
||||
app.config["JOB_THRESHOLD"] = 1
|
||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||
app.config["JOB_TIME"] = 600
|
||||
# memory limit for generator processes in bytes
|
||||
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
|
||||
# waitress uses one thread for I/O, these are for processing of views that then get sent
|
||||
# archipelago.gg uses gunicorn + nginx; ignoring this option
|
||||
@@ -61,30 +66,44 @@ cache = Cache()
|
||||
Compress(app)
|
||||
|
||||
|
||||
class B64UUIDConverter(BaseConverter):
|
||||
|
||||
def to_python(self, value):
|
||||
def to_python(value: str) -> uuid.UUID:
|
||||
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
||||
|
||||
def to_url(self, value):
|
||||
|
||||
def to_url(value: uuid.UUID) -> str:
|
||||
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||
|
||||
|
||||
class B64UUIDConverter(BaseConverter):
|
||||
|
||||
def to_python(self, value: str) -> uuid.UUID:
|
||||
return to_python(value)
|
||||
|
||||
def to_url(self, value: typing.Any) -> str:
|
||||
assert isinstance(value, uuid.UUID)
|
||||
return to_url(value)
|
||||
|
||||
|
||||
# short UUID
|
||||
app.url_map.converters["suuid"] = B64UUIDConverter
|
||||
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||
app.jinja_env.filters["suuid"] = to_url
|
||||
app.jinja_env.filters["title_sorted"] = title_sorted
|
||||
|
||||
|
||||
def register():
|
||||
def register() -> None:
|
||||
"""Import submodules, triggering their registering on flask routing.
|
||||
Note: initializes worlds subsystem."""
|
||||
import importlib
|
||||
|
||||
from werkzeug.utils import find_modules
|
||||
# has automatic patch integration
|
||||
import worlds.Files
|
||||
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
|
||||
|
||||
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, session
|
||||
|
||||
for module in find_modules("WebHostLib", include_packages=True):
|
||||
importlib.import_module(module)
|
||||
|
||||
from . import api
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -11,5 +11,5 @@ api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
|
||||
|
||||
|
||||
from . import datapackage, generate, room, user # trigger registration
|
||||
# trigger endpoint registration
|
||||
from . import datapackage, generate, room, tracker, user
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import json
|
||||
import pickle
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request, session, url_for
|
||||
from markupsafe import Markup
|
||||
from pony.orm import commit
|
||||
|
||||
from Utils import restricted_dumps
|
||||
from WebHostLib import app
|
||||
from WebHostLib.check import get_yaml_data, roll_options
|
||||
from WebHostLib.generate import get_meta
|
||||
@@ -56,7 +56,7 @@ def generate_api():
|
||||
"detail": results}, 400
|
||||
else:
|
||||
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
|
||||
meta=json.dumps(meta), state=STATE_QUEUED,
|
||||
owner=session["_id"])
|
||||
|
||||
@@ -3,6 +3,7 @@ from uuid import UUID
|
||||
|
||||
from flask import abort, url_for
|
||||
|
||||
from WebHostLib import to_url
|
||||
import worlds.Files
|
||||
from . import api_endpoints, get_players
|
||||
from ..models import Room
|
||||
@@ -33,7 +34,7 @@ def room_info(room_id: UUID) -> Dict[str, Any]:
|
||||
downloads.append(slot_download)
|
||||
|
||||
return {
|
||||
"tracker": room.tracker,
|
||||
"tracker": to_url(room.tracker),
|
||||
"players": get_players(room.seed),
|
||||
"last_port": room.last_port,
|
||||
"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 pony.orm import select
|
||||
|
||||
from WebHostLib import to_url
|
||||
from WebHostLib.models import Room, Seed
|
||||
from . import api_endpoints, get_players
|
||||
|
||||
@@ -10,13 +11,13 @@ def get_rooms():
|
||||
response = []
|
||||
for room in select(room for room in Room if room.owner == session["_id"]):
|
||||
response.append({
|
||||
"room_id": room.id,
|
||||
"seed_id": room.seed.id,
|
||||
"room_id": to_url(room.id),
|
||||
"seed_id": to_url(room.seed.id),
|
||||
"creation_time": room.creation_time,
|
||||
"last_activity": room.last_activity,
|
||||
"last_port": room.last_port,
|
||||
"timeout": room.timeout,
|
||||
"tracker": room.tracker,
|
||||
"tracker": to_url(room.tracker),
|
||||
})
|
||||
return jsonify(response)
|
||||
|
||||
@@ -26,7 +27,7 @@ def get_seeds():
|
||||
response = []
|
||||
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
|
||||
response.append({
|
||||
"seed_id": seed.id,
|
||||
"seed_id": to_url(seed.id),
|
||||
"creation_time": seed.creation_time,
|
||||
"players": get_players(seed),
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ from .locker import Locker, AlreadyRunningException
|
||||
_stop_event = Event()
|
||||
|
||||
|
||||
def stop():
|
||||
def stop() -> None:
|
||||
"""Stops previously launched threads"""
|
||||
global _stop_event
|
||||
stop_event = _stop_event
|
||||
@@ -36,25 +36,39 @@ def handle_generation_failure(result: BaseException):
|
||||
logging.exception(e)
|
||||
|
||||
|
||||
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
|
||||
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})")
|
||||
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
|
||||
try:
|
||||
return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout)
|
||||
finally:
|
||||
setproctitle(f"Generator (idle)")
|
||||
return res
|
||||
|
||||
|
||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None:
|
||||
try:
|
||||
meta = json.loads(generation.meta)
|
||||
options = restricted_loads(generation.options)
|
||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||
pool.apply_async(_mp_gen_game, (options,),
|
||||
{"meta": meta,
|
||||
pool.apply_async(
|
||||
_mp_gen_game,
|
||||
(options,),
|
||||
{
|
||||
"meta": meta,
|
||||
"sid": generation.id,
|
||||
"owner": generation.owner},
|
||||
handle_generation_success, handle_generation_failure)
|
||||
"owner": generation.owner,
|
||||
"timeout": timeout,
|
||||
},
|
||||
handle_generation_success,
|
||||
handle_generation_failure,
|
||||
)
|
||||
except Exception as e:
|
||||
generation.state = STATE_ERROR
|
||||
commit()
|
||||
@@ -135,6 +149,7 @@ def autogen(config: dict):
|
||||
|
||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
||||
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
||||
job_time = config["JOB_TIME"]
|
||||
with db_session:
|
||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||
|
||||
@@ -145,7 +160,7 @@ def autogen(config: dict):
|
||||
if sid:
|
||||
generation.delete()
|
||||
else:
|
||||
launch_generator(generator_pool, generation)
|
||||
launch_generator(generator_pool, generation, timeout=job_time)
|
||||
|
||||
commit()
|
||||
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
||||
@@ -157,16 +172,13 @@ def autogen(config: dict):
|
||||
generation for generation in Generation
|
||||
if generation.state == STATE_QUEUED).for_update()
|
||||
for generation in to_start:
|
||||
launch_generator(generator_pool, generation)
|
||||
launch_generator(generator_pool, generation, timeout=job_time)
|
||||
except AlreadyRunningException:
|
||||
logging.info("Autogen reports as already running, not starting another.")
|
||||
|
||||
Thread(target=keep_running, name="AP_Autogen").start()
|
||||
|
||||
|
||||
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
||||
|
||||
|
||||
class MultiworldInstance():
|
||||
def __init__(self, config: dict, id: int):
|
||||
self.room_ids = set()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import zipfile
|
||||
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 markupsafe import Markup
|
||||
@@ -43,7 +43,7 @@ def mysterycheck():
|
||||
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 = {}
|
||||
for uploaded_file in files:
|
||||
if banned_file(uploaded_file.filename):
|
||||
@@ -84,12 +84,12 @@ def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
|
||||
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"})) -> \
|
||||
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))
|
||||
results = {}
|
||||
rolled_results = {}
|
||||
results: dict[str, str | bool] = {}
|
||||
rolled_results: dict[str, dict] = {}
|
||||
for filename, text in options.items():
|
||||
try:
|
||||
if type(text) is dict:
|
||||
|
||||
@@ -19,7 +19,10 @@ from pony.orm import commit, db_session, select
|
||||
|
||||
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 .locker import Locker
|
||||
from .models import Command, GameDataPackage, Room, db
|
||||
@@ -86,10 +89,17 @@ class WebHostContext(Context):
|
||||
setattr(self, key, value)
|
||||
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||
|
||||
def listen_to_db_commands(self):
|
||||
async def listen_to_db_commands(self):
|
||||
cmdprocessor = DBCommandProcessor(self)
|
||||
|
||||
while not self.exit_event.is_set():
|
||||
await self.main_loop.run_in_executor(None, self._process_db_commands, cmdprocessor)
|
||||
try:
|
||||
await asyncio.wait_for(self.exit_event.wait(), 5)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
def _process_db_commands(self, cmdprocessor):
|
||||
with db_session:
|
||||
commands = select(command for command in Command if command.room.id == self.room_id)
|
||||
if commands:
|
||||
@@ -97,7 +107,6 @@ class WebHostContext(Context):
|
||||
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
|
||||
command.delete()
|
||||
commit()
|
||||
time.sleep(5)
|
||||
|
||||
@db_session
|
||||
def load(self, room_id: int):
|
||||
@@ -129,7 +138,7 @@ class WebHostContext(Context):
|
||||
else:
|
||||
row = GameDataPackage.get(checksum=game_data["checksum"])
|
||||
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
||||
game_data_packages[game] = Utils.restricted_loads(row.data)
|
||||
game_data_packages[game] = restricted_loads(row.data)
|
||||
continue
|
||||
else:
|
||||
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
||||
@@ -146,19 +155,20 @@ class WebHostContext(Context):
|
||||
self.location_name_groups = static_location_name_groups
|
||||
return self._load(multidata, game_data_packages, True)
|
||||
|
||||
@db_session
|
||||
def init_save(self, enabled: bool = True):
|
||||
self.saving = enabled
|
||||
if self.saving:
|
||||
with db_session:
|
||||
savegame_data = Room.get(id=self.room_id).multisave
|
||||
if savegame_data:
|
||||
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
|
||||
self.set_save(restricted_loads(savegame_data))
|
||||
self._start_async_saving(atexit_save=False)
|
||||
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
||||
asyncio.create_task(self.listen_to_db_commands())
|
||||
|
||||
@db_session
|
||||
def _save(self, exit_save: bool = False) -> bool:
|
||||
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())
|
||||
# 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
|
||||
@@ -224,6 +234,17 @@ def set_up_logging(room_id) -> logging.Logger:
|
||||
return logger
|
||||
|
||||
|
||||
def tear_down_logging(room_id):
|
||||
"""Close logging handling for a room."""
|
||||
logger_name = f"RoomLogger {room_id}"
|
||||
if logger_name in logging.Logger.manager.loggerDict:
|
||||
logger = logging.getLogger(logger_name)
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
del logging.Logger.manager.loggerDict[logger_name]
|
||||
|
||||
|
||||
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
||||
@@ -281,8 +302,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
assert ctx.server is None
|
||||
try:
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context())
|
||||
|
||||
functools.partial(server, ctx=ctx),
|
||||
ctx.host,
|
||||
ctx.port,
|
||||
ssl=get_ssl_context(),
|
||||
extensions=[server_per_message_deflate_factory],
|
||||
)
|
||||
await ctx.server
|
||||
except OSError: # likely port in use
|
||||
ctx.server = websockets.serve(
|
||||
@@ -303,6 +328,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = port
|
||||
del room
|
||||
else:
|
||||
ctx.logger.exception("Could not determine port. Likely hosting failure.")
|
||||
with db_session:
|
||||
@@ -315,28 +341,36 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
if ctx.saving:
|
||||
ctx._save()
|
||||
ctx._save(True)
|
||||
setattr(asyncio.current_task(), "save", None)
|
||||
except Exception as e:
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
room.last_port = -1
|
||||
del room
|
||||
logger.exception(e)
|
||||
raise
|
||||
else:
|
||||
if ctx.saving:
|
||||
ctx._save()
|
||||
ctx._save(True)
|
||||
setattr(asyncio.current_task(), "save", None)
|
||||
finally:
|
||||
try:
|
||||
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
|
||||
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
|
||||
with (db_session):
|
||||
|
||||
if ctx.server and hasattr(ctx.server, "ws_server"):
|
||||
ctx.server.ws_server.close()
|
||||
await ctx.server.ws_server.wait_closed()
|
||||
|
||||
with db_session:
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room = Room.get(id=room_id)
|
||||
room.last_activity = datetime.datetime.utcnow() - \
|
||||
datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
del room
|
||||
tear_down_logging(room_id)
|
||||
logging.info(f"Shutting down room {room_id} on {name}.")
|
||||
finally:
|
||||
await asyncio.sleep(5)
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import random
|
||||
import tempfile
|
||||
import zipfile
|
||||
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 pony.orm import commit, db_session
|
||||
|
||||
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 Utils import __version__
|
||||
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
|
||||
from WebHostLib import app
|
||||
from settings import ServerOptions, GeneratorOptions
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from .check import get_yaml_data, roll_options
|
||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
||||
from .upload import upload_zip_to_db
|
||||
|
||||
|
||||
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
|
||||
plando_options: Set[str] = set()
|
||||
def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | dict[str, Any]]:
|
||||
plando_options: set[str] = set()
|
||||
for substr in ("bosses", "items", "connections", "texts"):
|
||||
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
|
||||
plando_options.add(substr)
|
||||
@@ -34,6 +33,7 @@ def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[s
|
||||
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
|
||||
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_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))),
|
||||
"server_password": str(options_source.get("server_password", None)),
|
||||
}
|
||||
@@ -73,7 +73,11 @@ def generate(race=False):
|
||||
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"]))
|
||||
|
||||
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.")
|
||||
return redirect(url_for(request.endpoint, **(request.view_args or {})))
|
||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||
try:
|
||||
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
|
||||
meta=json.dumps(meta),
|
||||
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()
|
||||
|
||||
return redirect(url_for("wait_seed", seed=gen.id))
|
||||
else:
|
||||
try:
|
||||
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:
|
||||
from .autolauncher import handle_generation_failure
|
||||
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))
|
||||
|
||||
|
||||
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||
if not meta:
|
||||
meta: Dict[str, Any] = {}
|
||||
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None):
|
||||
if meta is None:
|
||||
meta = {}
|
||||
|
||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
||||
@@ -123,43 +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))
|
||||
|
||||
erargs = parse_arguments(['--multi', str(playercount)])
|
||||
erargs.seed = seed
|
||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
|
||||
erargs.race = race
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||
args = mystery_argparse([]) # Just to set up the Namespace with defaults
|
||||
args.multi = playercount
|
||||
args.seed = seed
|
||||
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||
args.spoiler = meta["generator_options"].get("spoiler", 0)
|
||||
args.race = race
|
||||
args.outputname = seedname
|
||||
args.outputpath = target.name
|
||||
args.teams = 1
|
||||
args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
erargs.skip_prog_balancing = False
|
||||
erargs.skip_output = False
|
||||
erargs.spoiler_only = False
|
||||
erargs.csv_output = False
|
||||
args.skip_prog_balancing = 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()
|
||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||
for k, v in settings.items():
|
||||
if v is not None:
|
||||
if hasattr(erargs, k):
|
||||
getattr(erargs, k)[player] = v
|
||||
if hasattr(args, k):
|
||||
getattr(args, k)[player] = v
|
||||
else:
|
||||
setattr(erargs, k, {player: v})
|
||||
setattr(args, k, {player: v})
|
||||
|
||||
if not erargs.name[player]:
|
||||
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
if len(set(erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
||||
if not args.name[player]:
|
||||
args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
||||
args.name[player] = handle_name(args.name[player], player, name_counter)
|
||||
if len(set(args.name.values())) != len(args.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}")
|
||||
ERmain(args, seed, baked_server_options=meta["server_options"])
|
||||
|
||||
return upload_to_db(target.name, sid, owner, race)
|
||||
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
|
||||
thread = thread_pool.submit(task)
|
||||
|
||||
try:
|
||||
return thread.result(app.config["JOB_TIME"])
|
||||
return thread.result(timeout)
|
||||
except concurrent.futures.TimeoutError as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
@@ -167,11 +185,14 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
||||
if gen is not None:
|
||||
gen.state = STATE_ERROR
|
||||
meta = json.loads(gen.meta)
|
||||
meta["error"] = (
|
||||
"Allowed time for Generation exceeded, please consider generating locally instead. " +
|
||||
e.__class__.__name__ + ": " + str(e))
|
||||
meta["error"] = ("Allowed time for Generation exceeded, " +
|
||||
"please consider generating locally instead. " +
|
||||
format_exception(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
commit()
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
# don't update db, retry next time
|
||||
raise
|
||||
except BaseException as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
@@ -179,10 +200,15 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
||||
if gen is not None:
|
||||
gen.state = STATE_ERROR
|
||||
meta = json.loads(gen.meta)
|
||||
meta["error"] = (e.__class__.__name__ + ": " + str(e))
|
||||
meta["error"] = format_exception(e)
|
||||
gen.meta = json.dumps(meta)
|
||||
commit()
|
||||
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>')
|
||||
@@ -196,7 +222,9 @@ def wait_seed(seed: UUID):
|
||||
if not generation:
|
||||
return "Generation not found."
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ import threading
|
||||
import json
|
||||
|
||||
from Utils import local_path, user_path
|
||||
from worlds.alttp.Rom import Sprite
|
||||
|
||||
|
||||
def update_sprites_lttp():
|
||||
from worlds.alttp.Rom import Sprite
|
||||
from tkinter import Tk
|
||||
from LttPAdjuster import get_image_for_sprite
|
||||
from LttPAdjuster import BackgroundTaskProgress
|
||||
@@ -14,7 +14,7 @@ def update_sprites_lttp():
|
||||
from LttPAdjuster import update_sprites
|
||||
|
||||
# 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
|
||||
|
||||
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 os
|
||||
import warnings
|
||||
from enum import StrEnum
|
||||
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
|
||||
|
||||
import jinja2.exceptions
|
||||
@@ -7,15 +9,39 @@ from flask import request, redirect, url_for, render_template, Response, session
|
||||
from pony.orm import count, commit, db_session
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from worlds.AutoWorld import AutoWorldRegister, World
|
||||
from . import app, cache
|
||||
from .markdown import render_markdown
|
||||
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):
|
||||
if game_name in AutoWorldRegister.world_types:
|
||||
return AutoWorldRegister.world_types[game_name].web.theme
|
||||
return 'grass'
|
||||
def get_visible_worlds() -> dict[str, type(World)]:
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world
|
||||
return worlds
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
@@ -31,83 +57,106 @@ def start_playing():
|
||||
return render_template(f"startPlaying.html")
|
||||
|
||||
|
||||
# Game Info Pages
|
||||
@app.route('/games/<string:game>/info/<string:lang>')
|
||||
@cache.cached()
|
||||
def game_info(game, lang):
|
||||
"""Game Info Pages"""
|
||||
try:
|
||||
world = AutoWorldRegister.world_types[game]
|
||||
if lang not in world.web.game_info_languages:
|
||||
raise KeyError("Sorry, this game's info page is not available in that language yet.")
|
||||
except KeyError:
|
||||
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)
|
||||
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
||||
|
||||
|
||||
# List of supported games
|
||||
@app.route('/games')
|
||||
@cache.cached()
|
||||
def games():
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world
|
||||
return render_template("supportedGames.html", worlds=worlds)
|
||||
"""List of supported games"""
|
||||
return render_template("supportedGames.html", worlds=get_visible_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>')
|
||||
@cache.cached()
|
||||
def tutorial(game, file, lang):
|
||||
try:
|
||||
world = AutoWorldRegister.world_types[game]
|
||||
if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]:
|
||||
raise KeyError("Sorry, the tutorial is not available in that language yet.")
|
||||
except KeyError:
|
||||
return abort(404)
|
||||
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
||||
def tutorial_redirect(game: str, file: str, lang: str):
|
||||
"""
|
||||
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/')
|
||||
@cache.cached()
|
||||
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
|
||||
}
|
||||
|
||||
worlds = dict(
|
||||
title_sorted(
|
||||
worlds.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>/')
|
||||
@cache.cached()
|
||||
def faq(lang: str):
|
||||
import markdown
|
||||
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
|
||||
document = f.read()
|
||||
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md"))
|
||||
return render_template(
|
||||
"markdown_document.html",
|
||||
title="Frequently Asked Questions",
|
||||
html_from_markdown=markdown.markdown(
|
||||
document,
|
||||
extensions=["toc", "mdx_breakless_lists"],
|
||||
extension_configs={
|
||||
"toc": {"anchorlink": True}
|
||||
}
|
||||
),
|
||||
html_from_markdown=document,
|
||||
)
|
||||
|
||||
|
||||
@app.route('/glossary/<string:lang>/')
|
||||
@cache.cached()
|
||||
def glossary(lang: str):
|
||||
import markdown
|
||||
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
|
||||
document = f.read()
|
||||
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md"))
|
||||
return render_template(
|
||||
"markdown_document.html",
|
||||
title="Glossary",
|
||||
html_from_markdown=markdown.markdown(
|
||||
document,
|
||||
extensions=["toc", "mdx_breakless_lists"],
|
||||
extension_configs={
|
||||
"toc": {"anchorlink": True}
|
||||
}
|
||||
),
|
||||
html_from_markdown=document,
|
||||
)
|
||||
|
||||
|
||||
@@ -188,7 +237,10 @@ def host_room(room: UUID):
|
||||
# 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))
|
||||
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
|
||||
|
||||
browser_tokens = "Mozilla", "Chrome", "Safari"
|
||||
@@ -196,9 +248,9 @@ def host_room(room: UUID):
|
||||
or "Discordbot" in request.user_agent.string
|
||||
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:
|
||||
return "…"
|
||||
return "…", 0
|
||||
try:
|
||||
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
||||
raw_size = 0
|
||||
@@ -209,9 +261,9 @@ def host_room(room: UUID):
|
||||
break
|
||||
raw_size += len(block)
|
||||
fragments.append(block.decode("utf-8"))
|
||||
return "".join(fragments)
|
||||
return "".join(fragments), raw_size
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
return "", 0
|
||||
|
||||
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from Utils import local_path
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import app, cache
|
||||
from .generate import get_meta
|
||||
from .misc import get_world_theme
|
||||
|
||||
|
||||
def create() -> None:
|
||||
@@ -22,12 +23,6 @@ def create() -> None:
|
||||
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]:
|
||||
world = AutoWorldRegister.world_types[world_name]
|
||||
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()
|
||||
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,
|
||||
'file_insertion_enabled': False,
|
||||
'output_encoding': 'unicode'
|
||||
@@ -155,7 +150,9 @@ def generate_weighted_yaml(game: str):
|
||||
options = {}
|
||||
|
||||
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:
|
||||
continue
|
||||
|
||||
@@ -212,8 +209,11 @@ def generate_yaml(game: str):
|
||||
if request.method == "POST":
|
||||
options = {}
|
||||
intent_generate = False
|
||||
|
||||
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):
|
||||
options[key] = [options[key]]
|
||||
options[key].append(val)
|
||||
@@ -226,7 +226,7 @@ def generate_yaml(game: str):
|
||||
if key_parts[-1] == "qty":
|
||||
if key_parts[0] not in options:
|
||||
options[key_parts[0]] = {}
|
||||
if val != "0":
|
||||
if val and val != "0":
|
||||
options[key_parts[0]][key_parts[1]] = int(val)
|
||||
del options[key]
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
flask>=3.1.1
|
||||
werkzeug>=3.1.3
|
||||
pony>=0.7.19
|
||||
pony>=0.7.19; python_version <= '3.12'
|
||||
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-Compress>=1.17
|
||||
Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
|
||||
Flask-Limiter>=3.12
|
||||
bokeh>=3.6.3
|
||||
markupsafe>=3.0.2
|
||||
Markdown>=3.7
|
||||
mdx-breakless-lists>=1.0.1
|
||||
setproctitle>=1.3.5
|
||||
mistune>=3.1.3
|
||||
docutils>=0.22.2
|
||||
|
||||
@@ -23,7 +23,7 @@ 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
|
||||
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 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?
|
||||
|
||||
@@ -33,7 +33,7 @@ play, open the Settings Page, pick your settings, and click Generate Game.
|
||||
|
||||
## 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
|
||||
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
|
||||
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?
|
||||
|
||||
@@ -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
|
||||
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:
|
||||
[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:
|
||||
[/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,45 +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);
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,21 @@
|
||||
let updateSection = (sectionName, fakeDOM) => {
|
||||
document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
// Reload tracker every 15 seconds
|
||||
// Reload tracker every 60 seconds (sync'd)
|
||||
const url = window.location;
|
||||
setInterval(() => {
|
||||
// Note: This synchronization code is adapted from code in trackerCommon.js
|
||||
const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3;
|
||||
console.log("Target second of refresh: " + targetSecond);
|
||||
|
||||
let getSleepTimeSeconds = () => {
|
||||
// -40 % 60 is -40, which is absolutely wrong and should burn
|
||||
var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60;
|
||||
return sleepSeconds || 60;
|
||||
};
|
||||
|
||||
let updateTracker = () => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
@@ -10,40 +24,20 @@ window.addEventListener('load', () => {
|
||||
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;
|
||||
}
|
||||
// Update dynamic sections
|
||||
updateSection('player-info', fakeDOM);
|
||||
updateSection('section-filler', fakeDOM);
|
||||
updateSection('section-terran', fakeDOM);
|
||||
updateSection('section-zerg', fakeDOM);
|
||||
updateSection('section-protoss', fakeDOM);
|
||||
updateSection('section-nova', fakeDOM);
|
||||
updateSection('section-kerrigan', fakeDOM);
|
||||
updateSection('section-keys', fakeDOM);
|
||||
updateSection('section-locations', fakeDOM);
|
||||
};
|
||||
ajax.open('GET', url);
|
||||
ajax.send();
|
||||
}, 15000)
|
||||
|
||||
// Collapsible advancement sections
|
||||
const categories = document.getElementsByClassName("location-category");
|
||||
for (let category of categories) {
|
||||
let hide_id = category.id.split('_')[0];
|
||||
if (hide_id === 'Total') {
|
||||
continue;
|
||||
}
|
||||
category.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;
|
||||
});
|
||||
}
|
||||
updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
||||
};
|
||||
window.updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
||||
});
|
||||
|
||||
@@ -1,52 +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);
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -28,7 +28,6 @@
|
||||
font-weight: normal;
|
||||
font-family: LondrinaSolid-Regular, sans-serif;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
||||
width: 100%;
|
||||
text-shadow: 1px 1px 4px #000000;
|
||||
}
|
||||
@@ -37,7 +36,6 @@
|
||||
font-size: 38px;
|
||||
font-weight: normal;
|
||||
font-family: LondrinaSolid-Light, sans-serif;
|
||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
@@ -50,7 +48,6 @@
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
text-transform: none;
|
||||
text-align: left;
|
||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
@@ -59,7 +56,6 @@
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
text-transform: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@@ -67,14 +63,12 @@
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
text-transform: none;
|
||||
font-size: 22px;
|
||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
||||
}
|
||||
|
||||
.markdown h6, .markdown details summary.h6{
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
text-transform: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
||||
}
|
||||
|
||||
.markdown h4, .markdown h5, .markdown h6{
|
||||
|
||||
@@ -1,160 +1,276 @@
|
||||
#player-tracker-wrapper{
|
||||
*{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#tracker-table td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.inventory-table-area{
|
||||
border: 2px solid #000000;
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px 3px 10px;
|
||||
}
|
||||
|
||||
.inventory-table-area:has(.inventory-table-terran) {
|
||||
width: 690px;
|
||||
background-color: #525494;
|
||||
}
|
||||
|
||||
.inventory-table-area:has(.inventory-table-zerg) {
|
||||
width: 360px;
|
||||
background-color: #9d60d2;
|
||||
}
|
||||
|
||||
.inventory-table-area:has(.inventory-table-protoss) {
|
||||
width: 400px;
|
||||
background-color: #d2b260;
|
||||
}
|
||||
|
||||
#tracker-table .inventory-table td{
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.inventory-table td.title{
|
||||
padding-top: 10px;
|
||||
height: 20px;
|
||||
font-family: "JuraBook", monospace;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
body{
|
||||
--icon-size: 36px;
|
||||
--item-class-padding: 4px;
|
||||
}
|
||||
a{
|
||||
color: #1ae;
|
||||
}
|
||||
|
||||
.inventory-table img{
|
||||
height: 100%;
|
||||
max-width: 40px;
|
||||
max-height: 40px;
|
||||
border: 1px solid #000000;
|
||||
filter: grayscale(100%) contrast(75%) brightness(20%);
|
||||
background-color: black;
|
||||
/* Section colours */
|
||||
#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 img.acquired{
|
||||
filter: none;
|
||||
background-color: black;
|
||||
/* Sections */
|
||||
.section-body{
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.inventory-table .tint-terran img.acquired {
|
||||
filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg)
|
||||
.section-body-2{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.inventory-table .tint-protoss img.acquired {
|
||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg)
|
||||
}
|
||||
|
||||
.inventory-table .tint-level-1 img.acquired {
|
||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg)
|
||||
}
|
||||
|
||||
.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 {
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Acquire item filters */
|
||||
.tracker-section img{
|
||||
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);
|
||||
}
|
||||
|
||||
/* Item groups */
|
||||
.item-class{
|
||||
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;
|
||||
}
|
||||
|
||||
/* Subsections */
|
||||
.section-toc{
|
||||
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;
|
||||
}
|
||||
|
||||
/* Progressive items */
|
||||
.progressive{
|
||||
max-height: var(--icon-size);
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.lvl-0 > :nth-child(2),
|
||||
.lvl-0 > :nth-child(3),
|
||||
.lvl-0 > :nth-child(4),
|
||||
.lvl-0 > :nth-child(5){
|
||||
display: none;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Filler item counters */
|
||||
.item-counter{
|
||||
display: table;
|
||||
text-align: center;
|
||||
padding: var(--item-class-padding);
|
||||
}
|
||||
.item-count{
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
padding-left: 3px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
/* Hidden items */
|
||||
.hidden-class:not(:has(.f:not(.unacquired))), .hidden-item{
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Keys */
|
||||
#keys ol, #keys ul{
|
||||
columns: 3;
|
||||
-webkit-columns: 3;
|
||||
-moz-columns: 3;
|
||||
}
|
||||
#keys li{
|
||||
padding-right: 15pt;
|
||||
}
|
||||
|
||||
/* Locations */
|
||||
#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: "✔ ";
|
||||
}
|
||||
|
||||
/* Allowing scrolling down a little further */
|
||||
.bottom-padding{
|
||||
min-height: 33vh;
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -13,3 +13,7 @@
|
||||
min-height: 360px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h2, h4 {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import typing
|
||||
from collections import Counter, defaultdict
|
||||
from colorsys import hsv_to_rgb
|
||||
from datetime import datetime, timedelta, date
|
||||
@@ -18,21 +17,23 @@ from .models import Room
|
||||
PLOT_WIDTH = 600
|
||||
|
||||
|
||||
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
|
||||
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
|
||||
games_played = defaultdict(Counter)
|
||||
total_games = Counter()
|
||||
def get_db_data(known_games: set[str]) -> tuple[Counter[str], defaultdict[date, dict[str, int]]]:
|
||||
games_played: defaultdict[date, dict[str, int]] = defaultdict(Counter)
|
||||
total_games: Counter[str] = Counter()
|
||||
cutoff = date.today() - timedelta(days=30)
|
||||
room: Room
|
||||
for room in select(room for room in Room if room.creation_time >= cutoff):
|
||||
for slot in room.seed.slots:
|
||||
if slot.game in known_games:
|
||||
total_games[slot.game] += 1
|
||||
games_played[room.creation_time.date()][slot.game] += 1
|
||||
current_game = slot.game
|
||||
else:
|
||||
current_game = "Other"
|
||||
total_games[current_game] += 1
|
||||
games_played[room.creation_time.date()][current_game] += 1
|
||||
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_needed +1 to prevent first and last color being too close to each other
|
||||
colors_needed += 1
|
||||
@@ -47,8 +48,7 @@ def get_color_palette(colors_needed: int) -> typing.List[RGB]:
|
||||
return colors
|
||||
|
||||
|
||||
def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
|
||||
game: str, color: RGB) -> figure:
|
||||
def create_game_played_figure(all_games_data: dict[date, dict[str, int]], game: str, color: RGB) -> figure:
|
||||
occurences = []
|
||||
days = [day for day, game_data in all_games_data.items() if game_data[game]]
|
||||
for day in days:
|
||||
@@ -84,7 +84,7 @@ def stats():
|
||||
days = sorted(games_played)
|
||||
|
||||
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):
|
||||
occurences = []
|
||||
|
||||
@@ -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>
|
||||
{% if hint.finding_player == player %}
|
||||
<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>
|
||||
{% else %}
|
||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
|
||||
@@ -109,7 +109,7 @@
|
||||
<td>
|
||||
{% if hint.receiving_player == player %}
|
||||
<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>
|
||||
{% else %}
|
||||
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
|
||||
|
||||
@@ -58,8 +58,7 @@
|
||||
Open Log File...
|
||||
</a>
|
||||
</div>
|
||||
{% set log = get_log() -%}
|
||||
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
|
||||
{% set log, log_len = get_log() -%}
|
||||
<div id="logger" style="white-space: pre">{{ log }}</div>
|
||||
<script>
|
||||
let url = '{{ url_for('display_log', room = room.id) }}';
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
{% 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>
|
||||
Download APSM64EX 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 | is_applayercontainer(patch.data, patch.player_id) %}
|
||||
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
||||
Download Patch File...</a>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
{% set theme_name = theme|default("grass", true) %}
|
||||
{% include "header/"+theme_name+"Header.html" %}
|
||||
<title>{{ title }}</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -45,15 +45,15 @@
|
||||
{%- set current_sphere = loop.index %}
|
||||
{%- for player, sphere_location_ids in sphere.items() %}
|
||||
{%- set checked_locations = tracker_data.get_player_checked_locations(team, player) %}
|
||||
{%- set finder_game = tracker_data.get_player_game(team, player) %}
|
||||
{%- set player_location_data = tracker_data.get_player_locations(team, player) %}
|
||||
{%- set finder_game = tracker_data.get_player_game(player) %}
|
||||
{%- set player_location_data = tracker_data.get_player_locations(player) %}
|
||||
{%- for location_id in sphere_location_ids.intersection(checked_locations) %}
|
||||
<tr>
|
||||
{%- 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>{{ tracker_data.get_player_name(team, player) }}</td>
|
||||
<td>{{ tracker_data.get_player_name(team, receiver) }}</td>
|
||||
<td>{{ tracker_data.get_player_name(player) }}</td>
|
||||
<td>{{ tracker_data.get_player_name(receiver) }}</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>{{ finder_game }}</td>
|
||||
|
||||
@@ -22,14 +22,14 @@
|
||||
-%}
|
||||
<tr>
|
||||
<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>
|
||||
{% else %}
|
||||
{{ player_names_with_alias[(team, hint.finding_player)] }}
|
||||
{% endif %}
|
||||
</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>
|
||||
{% else %}
|
||||
{{ player_names_with_alias[(team, hint.receiving_player)] }}
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
{{ OptionTitle(option_name, option) }}
|
||||
<div class="named-range-container">
|
||||
<select id="{{ option_name }}-select" name="{{ option_name }}" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||
{% if option.default not in option.special_range_names.values() %}
|
||||
<option value="{{ option.default }}" selected>Default ({{ option.default }})</option>
|
||||
{% endif %}
|
||||
{% for key, val in option.special_range_names.items() %}
|
||||
{% if option.default == val %}
|
||||
<option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
|
||||
@@ -94,6 +97,9 @@
|
||||
<div class="text-choice-container">
|
||||
<div class="text-choice-wrapper">
|
||||
<select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
|
||||
{% if option.default not in option.options.values() %}
|
||||
<option value="{{ option.default }}" selected>Default ({{ option.default }})</option>
|
||||
{% endif %}
|
||||
{% for id, name in option.name_lookup.items()|sort %}
|
||||
{% if name != "random" %}
|
||||
{% if option.default == id %}
|
||||
@@ -134,6 +140,7 @@
|
||||
|
||||
{% macro OptionList(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">
|
||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||
<div class="option-entry">
|
||||
@@ -146,6 +153,7 @@
|
||||
|
||||
{% macro LocationSet(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">
|
||||
{% for group_name in world.location_name_groups.keys()|sort %}
|
||||
{% if group_name != "Everywhere" %}
|
||||
@@ -169,6 +177,7 @@
|
||||
|
||||
{% macro ItemSet(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">
|
||||
{% for group_name in world.item_name_groups.keys()|sort %}
|
||||
{% if group_name != "Everything" %}
|
||||
@@ -192,6 +201,7 @@
|
||||
|
||||
{% macro OptionSet(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">
|
||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||
<div class="option-entry">
|
||||
|
||||
@@ -4,16 +4,20 @@
|
||||
|
||||
{% block head %}
|
||||
<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 %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/oceanIslandHeader.html' %}
|
||||
<div id="wait-seed-wrapper" class="grass-island">
|
||||
<div id="wait-seed">
|
||||
<h1>Generation failed</h1>
|
||||
<h2>please retry</h2>
|
||||
{{ seed_error }}
|
||||
<h1>Generation Failed</h1>
|
||||
<h2>Please try again!</h2>
|
||||
<p>{{ seed_error }}</p>
|
||||
<h4>More details:</h4>
|
||||
<p>
|
||||
<code class="grassy">{{ details }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,32 +11,32 @@
|
||||
<h1>Site Map</h1>
|
||||
<h2>Base Pages</h2>
|
||||
<ul>
|
||||
<li><a href="/discord">Discord Link</a></li>
|
||||
<li><a href="/faq/en">F.A.Q. Page</a></li>
|
||||
<li><a href="/favicon.ico">Favicon</a></li>
|
||||
<li><a href="/generate">Generate Game Page</a></li>
|
||||
<li><a href="/">Homepage</a></li>
|
||||
<li><a href="/uploads">Host Game Page</a></li>
|
||||
<li><a href="/datapackage">Raw Data Package</a></li>
|
||||
<li><a href="{{ url_for('check')}}">Settings Validator</a></li>
|
||||
<li><a href="/sitemap">Site Map</a></li>
|
||||
<li><a href="/start-playing">Start Playing</a></li>
|
||||
<li><a href="/games">Supported Games Page</a></li>
|
||||
<li><a href="/tutorial">Tutorials Page</a></li>
|
||||
<li><a href="/user-content">User Content</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("show_session")}}">Session / Login</a></li>
|
||||
<li><a href="{{ url_for('discord') }}">Discord Link</a></li>
|
||||
<li><a href="{{ url_for('faq', lang='en') }}">F.A.Q. Page</a></li>
|
||||
<li><a href="{{ url_for('favicon') }}">Favicon</a></li>
|
||||
<li><a href="{{ url_for('generate') }}">Generate Game Page</a></li>
|
||||
<li><a href="{{ url_for('landing') }}">Homepage</a></li>
|
||||
<li><a href="{{ url_for('uploads') }}">Host Game Page</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('get_sitemap') }}">Site Map</a></li>
|
||||
<li><a href="{{ url_for('start_playing') }}">Start Playing</a></li>
|
||||
<li><a href="{{ url_for('games') }}">Supported Games Page</a></li>
|
||||
<li><a href="{{ url_for('tutorial_landing') }}">Tutorials Page</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('glossary', lang='en') }}">Glossary</a></li>
|
||||
<li><a href="{{ url_for('show_session') }}">Session / Login</a></li>
|
||||
</ul>
|
||||
|
||||
<h2>Tutorials</h2>
|
||||
<ul>
|
||||
<li><a href="/tutorial/Archipelago/setup/en">Multiworld Setup Tutorial</a></li>
|
||||
<li><a href="/tutorial/Archipelago/mac/en">Setup Guide for Mac</a></li>
|
||||
<li><a href="/tutorial/Archipelago/commands/en">Server and Client Commands</a></li>
|
||||
<li><a href="/tutorial/Archipelago/advanced_settings/en">Advanced YAML Guide</a></li>
|
||||
<li><a href="/tutorial/Archipelago/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='setup_en') }}">Multiworld Setup Tutorial</a></li>
|
||||
<li><a href="{{ url_for('tutorial', game='Archipelago', file='mac_en') }}">Setup Guide for Mac</a></li>
|
||||
<li><a href="{{ url_for('tutorial', game='Archipelago', file='commands_en') }}">Server and Client Commands</a></li>
|
||||
<li><a href="{{ url_for('tutorial', game='Archipelago', file='advanced_settings_en') }}">Advanced YAML Guide</a></li>
|
||||
<li><a href="{{ url_for('tutorial', game='Archipelago', file='triggers_en') }}">Triggers Guide</a></li>
|
||||
<li><a href="{{ url_for('tutorial', game='Archipelago', file='plando_en') }}">Plando Guide</a></li>
|
||||
</ul>
|
||||
|
||||
<h2>Game Info Pages</h2>
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
{% include 'header/oceanHeader.html' %}
|
||||
<div id="games" class="markdown">
|
||||
<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">
|
||||
<label for="game-search">Search for your game below!</label><br />
|
||||
<div class="page-controls">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{% include 'header/'+theme+'Header.html' %}
|
||||
<title>Archipelago</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/tutorial.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id="tutorial-wrapper" class="markdown" data-game="{{ game | get_file_safe_name }}" data-file="{{ file | get_file_safe_name }}" data-lang="{{ lang }}">
|
||||
<!-- Content generated by JavaScript -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -3,14 +3,32 @@
|
||||
{% block head %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
<title>Archipelago Guides</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/tutorialLanding.css") }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorialLanding.js") }}"></script>
|
||||
<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/tutorialLanding.css") }}"/>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id="tutorial-landing" class="markdown" data-game="{{ game }}" data-file="{{ file }}" data-lang="{{ lang }}">
|
||||
<h1 id="page-title">Archipelago Guides</h1>
|
||||
<p id="loading">Loading...</p>
|
||||
<div id="tutorial-landing" class="markdown">
|
||||
<h1>Archipelago Guides</h1>
|
||||
{% for world_name, world_type in worlds.items() %}
|
||||
<h2 id="{{ world_type.game | urlencode }}">{{ world_type.game }}</h2>
|
||||
{% for tutorial_name, tutorial_data in tutorials[world_name].items() %}
|
||||
<h3>{{ tutorial_name }}</h3>
|
||||
<p>{{ tutorial_data.description }}</p>
|
||||
<p>This guide is available in the following languages:</p>
|
||||
<ul>
|
||||
{% for file_name, file_data in tutorial_data.files.items() %}
|
||||
<li>
|
||||
<a href="{{ url_for("tutorial", game=world_name, file=file_name) }}">{{ file_data.language }}</a>
|
||||
by
|
||||
{% for author in file_data.authors %}
|
||||
{{ author }}
|
||||
{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% macro OptionList(option_name, option) %}
|
||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||
<div class="list-container">
|
||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||
<div class="list-entry">
|
||||
@@ -158,6 +159,7 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% macro LocationSet(option_name, option, world) %}
|
||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||
<div class="set-container">
|
||||
{% for group_name in world.location_name_groups.keys()|sort %}
|
||||
{% if group_name != "Everywhere" %}
|
||||
@@ -180,6 +182,7 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% macro ItemSet(option_name, option, world) %}
|
||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||
<div class="set-container">
|
||||
{% for group_name in world.item_name_groups.keys()|sort %}
|
||||
{% if group_name != "Everything" %}
|
||||
@@ -202,6 +205,7 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% macro OptionSet(option_name, option) %}
|
||||
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||
<div class="set-container">
|
||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||
<div class="set-entry">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
import base64
|
||||
import json
|
||||
import pickle
|
||||
import typing
|
||||
@@ -14,9 +13,8 @@ from pony.orm.core import TransactionIntegrityError
|
||||
import schema
|
||||
|
||||
import MultiServer
|
||||
from NetUtils import SlotType
|
||||
from NetUtils import GamesPackage, SlotType
|
||||
from Utils import VersionException, __version__
|
||||
from worlds import GamesPackage
|
||||
from worlds.Files import AutoPatchRegister
|
||||
from worlds.AutoWorld import data_package_checksum
|
||||
from . import app
|
||||
|
||||
@@ -20,6 +20,8 @@ from worlds.tloz.Items import item_game_ids
|
||||
from worlds.tloz.Locations import location_ids
|
||||
from worlds.tloz import Items, Locations, Rom
|
||||
|
||||
from settings import get_settings
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_tloz.lua"
|
||||
@@ -287,7 +289,7 @@ async def nes_sync_task(ctx: ZeldaContext):
|
||||
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"
|
||||
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)
|
||||
@@ -333,6 +335,7 @@ async def nes_sync_task(ctx: ZeldaContext):
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
|
||||
@@ -340,13 +343,12 @@ if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
Utils.init_logging("ZeldaClient")
|
||||
|
||||
options = Utils.get_options()
|
||||
DISPLAY_MSGS = options["tloz_options"]["display_msgs"]
|
||||
DISPLAY_MSGS = get_settings()["tloz_options"]["display_msgs"]
|
||||
|
||||
|
||||
async def run_game(romfile: str) -> None:
|
||||
auto_start = typing.cast(typing.Union[bool, str],
|
||||
Utils.get_options()["tloz_options"].get("rom_start", True))
|
||||
get_settings()["tloz_options"].get("rom_start", True))
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
|
||||
2
ci-requirements.txt
Normal file
2
ci-requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pytest>=9.0.1,<10 # this includes subtests support
|
||||
pytest-xdist>=3.8.0
|
||||
13
data/GLOBAL.apignore
Normal file
13
data/GLOBAL.apignore
Normal file
@@ -0,0 +1,13 @@
|
||||
# This file specifies patterns that are ignored by default for any world built with the "Build APWorlds" component.
|
||||
# These patterns can be overriden by a world-specific .apignore using !-prefixed patterns for negation.
|
||||
|
||||
# Auto-created folders
|
||||
__MACOSX
|
||||
.DS_Store
|
||||
__pycache__
|
||||
|
||||
# Unneeded files
|
||||
/archipelago.json
|
||||
/.apignore
|
||||
/.git
|
||||
/.gitignore
|
||||
@@ -220,8 +220,11 @@
|
||||
<MessageBoxLabel>:
|
||||
theme_text_color: "Custom"
|
||||
text_color: 1, 1, 1, 1
|
||||
<MessageBox>:
|
||||
height: self.content.texture_size[1] + 80
|
||||
<ScrollBox>:
|
||||
layout: layout
|
||||
box_height: dp(100)
|
||||
bar_width: "12dp"
|
||||
scroll_wheel_distance: 40
|
||||
do_scroll_x: False
|
||||
@@ -232,9 +235,11 @@
|
||||
orientation: "vertical"
|
||||
spacing: 10
|
||||
size_hint_y: None
|
||||
height: self.minimum_height
|
||||
height: max(self.minimum_height, root.box_height)
|
||||
|
||||
<MessageBoxLabel>:
|
||||
valign: "middle"
|
||||
halign: "center"
|
||||
text_size: self.width, None
|
||||
height: self.texture_size[1]
|
||||
|
||||
|
||||
@@ -28,17 +28,21 @@
|
||||
name: Player{number}
|
||||
|
||||
# Used to describe your yaml. Useful if you have multiple files.
|
||||
description: {{ yaml_dump("Default %s Template" % game) }}
|
||||
description: {{ yaml_dump("%s Preset for %s" % (preset_name, game)) if preset_name else yaml_dump("Default %s Template" % game) }}
|
||||
|
||||
game: {{ yaml_dump(game) }}
|
||||
requires:
|
||||
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
|
||||
{%- if world_version != "0.0.0" %}
|
||||
game:
|
||||
{{ yaml_dump(game) }}: {{ world_version }} # Version of the world required for this yaml to work as expected.
|
||||
{%- endif %}
|
||||
|
||||
{%- macro range_option(option) %}
|
||||
{%- macro range_option(option, option_val) %}
|
||||
# You can define additional values between the minimum and maximum values.
|
||||
# Minimum value is {{ option.range_start }}
|
||||
# Maximum value is {{ option.range_end }}
|
||||
{%- set data, notes = dictify_range(option) %}
|
||||
{%- set data, notes = dictify_range(option, option_val) %}
|
||||
{%- for entry, default in data.items() %}
|
||||
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
|
||||
{%- endfor -%}
|
||||
@@ -46,10 +50,16 @@ requires:
|
||||
|
||||
{{ yaml_dump(game) }}:
|
||||
{%- for group_name, group_options in option_groups.items() %}
|
||||
# {{ group_name }}
|
||||
##{% for _ in group_name %}#{% endfor %}##
|
||||
# {{ group_name }} #
|
||||
##{% for _ in group_name %}#{% endfor %}##
|
||||
|
||||
{%- for option_key, option in group_options.items() %}
|
||||
{{ option_key }}:
|
||||
{%- set option_val = option.default %}
|
||||
{%- if option_key in preset %}
|
||||
{%- set option_val = preset[option_key] %}
|
||||
{%- endif -%}
|
||||
{%- if option.__doc__ %}
|
||||
# {{ cleandoc(option.__doc__)
|
||||
| trim
|
||||
@@ -63,25 +73,25 @@ requires:
|
||||
{%- endif -%}
|
||||
|
||||
{%- if option.range_start is defined and option.range_start is number %}
|
||||
{{- range_option(option) -}}
|
||||
{{- range_option(option, option_val) -}}
|
||||
|
||||
{%- elif option.options -%}
|
||||
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
|
||||
{{ yaml_dump(sub_option_name) }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
|
||||
{{ yaml_dump(sub_option_name) }}: {% if suboption_option_id == option_val or sub_option_name == option_val %}50{% else %}0{% endif %}
|
||||
{%- endfor -%}
|
||||
|
||||
{%- if option.name_lookup[option.default] not in option.options %}
|
||||
{{ yaml_dump(option.default) }}: 50
|
||||
{%- if option.name_lookup[option_val] not in option.options and option_val not in option.options %}
|
||||
{{ yaml_dump(option_val) }}: 50
|
||||
{%- endif -%}
|
||||
|
||||
{%- elif option.default is string %}
|
||||
{{ yaml_dump(option.default) }}: 50
|
||||
{%- elif option_val is string %}
|
||||
{{ yaml_dump(option_val) }}: 50
|
||||
|
||||
{%- elif option.default is iterable and option.default is not mapping %}
|
||||
{{ option.default | list }}
|
||||
{%- elif option_val is iterable and option_val is not mapping %}
|
||||
{{ option_val | list }}
|
||||
|
||||
{%- else %}
|
||||
{{ yaml_dump(option.default) | indent(4, first=false) }}
|
||||
{{ yaml_dump(option_val) | indent(4, first=false) }}
|
||||
{%- endif -%}
|
||||
{{ "\n" }}
|
||||
{%- endfor %}
|
||||
|
||||
174
data/optionscreator.kv
Normal file
174
data/optionscreator.kv
Normal file
@@ -0,0 +1,174 @@
|
||||
<VisualRange>:
|
||||
id: this
|
||||
spacing: 15
|
||||
orientation: "horizontal"
|
||||
slider: slider
|
||||
tag: tag
|
||||
MDLabel:
|
||||
id: tag
|
||||
text: str(this.option.default) if not isinstance(this.option.default, str) else str(this.option.range_start)
|
||||
MDSlider:
|
||||
id: slider
|
||||
min: this.option.range_start
|
||||
max: this.option.range_end
|
||||
value: min(max(this.option.default, this.option.range_start), this.option.range_end) if not isinstance(this.option.default, str) else this.option.range_start
|
||||
step: 1
|
||||
step_point_size: 0
|
||||
MDSliderHandle:
|
||||
|
||||
MDSliderValueLabel:
|
||||
|
||||
<VisualChoice>:
|
||||
id: this
|
||||
text: text
|
||||
MDButtonText:
|
||||
id: text
|
||||
text: this.option.get_option_name(this.option.default if not isinstance(this.option.default, str) else list(this.option.options.values())[0])
|
||||
theme_text_color: "Primary"
|
||||
|
||||
<VisualNamedRange>:
|
||||
id: this
|
||||
orientation: "horizontal"
|
||||
spacing: "10dp"
|
||||
padding: (0, 0, "10dp", 0)
|
||||
choice: choice
|
||||
|
||||
MDButton:
|
||||
id: choice
|
||||
text: text
|
||||
MDButtonText:
|
||||
id: text
|
||||
text: this.option.default.title() if this.option.default in this.option.special_range_names else "Custom"
|
||||
|
||||
<VisualFreeText>:
|
||||
multiline: False
|
||||
font_size: "15sp"
|
||||
text: self.option.default if isinstance(self.option.default, str) else ""
|
||||
theme_height: "Custom"
|
||||
height: "30dp"
|
||||
|
||||
|
||||
<VisualTextChoice>:
|
||||
id: this
|
||||
orientation: "horizontal"
|
||||
spacing: "5dp"
|
||||
padding: (0, 0, "10dp", 0)
|
||||
|
||||
<VisualToggle>:
|
||||
id: this
|
||||
button: button
|
||||
MDIconButton:
|
||||
id: button
|
||||
icon: "checkbox-outline" if this.option.default else "checkbox-blank-outline"
|
||||
|
||||
<VisualListSetEntry@ResizableTextField>:
|
||||
height: "20dp"
|
||||
|
||||
<CounterItemValue>:
|
||||
height: "30dp"
|
||||
|
||||
<VisualListSetCounter>:
|
||||
id: this
|
||||
scrollbox: scrollbox
|
||||
add: add
|
||||
save: save
|
||||
input: input
|
||||
focus_behavior: False
|
||||
|
||||
MDDialogHeadlineText:
|
||||
text: getattr(this.option, "display_name", this.name)
|
||||
|
||||
MDDialogSupportingText:
|
||||
text: "Add or Remove Entries"
|
||||
|
||||
MDDialogContentContainer:
|
||||
orientation: "vertical"
|
||||
spacing: 10
|
||||
|
||||
MDBoxLayout:
|
||||
orientation: "horizontal"
|
||||
VisualListSetEntry:
|
||||
id: input
|
||||
height: "20dp"
|
||||
|
||||
MDIconButton:
|
||||
id: add
|
||||
icon: "plus"
|
||||
theme_height: "Custom"
|
||||
height: "20dp"
|
||||
on_press: root.validate_add(input)
|
||||
|
||||
ScrollBox:
|
||||
id: scrollbox
|
||||
size_hint_y: None
|
||||
adapt_minimum: False
|
||||
|
||||
MDButton:
|
||||
id: save
|
||||
MDButtonText:
|
||||
text: "Save Changes"
|
||||
|
||||
ContainerLayout:
|
||||
md_bg_color: app.theme_cls.backgroundColor
|
||||
|
||||
MainLayout:
|
||||
id: main
|
||||
cols: 3
|
||||
padding: 3, 5, 0, 3
|
||||
spacing: "2dp"
|
||||
|
||||
ScrollBox:
|
||||
id: scrollbox
|
||||
size_hint_x: None
|
||||
width: "150dp"
|
||||
|
||||
MDDivider:
|
||||
orientation: "vertical"
|
||||
width: "4dp"
|
||||
|
||||
MainLayout:
|
||||
id: player_layout
|
||||
rows: 2
|
||||
spacing: "20dp"
|
||||
|
||||
MDBoxLayout:
|
||||
id: player_options
|
||||
orientation: "horizontal"
|
||||
height: "75dp"
|
||||
size_hint_y: None
|
||||
padding: ["10dp", "30dp", "10dp", 0]
|
||||
spacing: "10dp"
|
||||
|
||||
ResizableTextField:
|
||||
id: player_name
|
||||
multiline: False
|
||||
|
||||
MDTextFieldHintText:
|
||||
text: "Player Name"
|
||||
|
||||
MDTextFieldMaxLengthText:
|
||||
max_text_length: 16
|
||||
|
||||
MDBoxLayout:
|
||||
orientation: "vertical"
|
||||
spacing: "15dp"
|
||||
|
||||
MDLabel:
|
||||
id: game
|
||||
text: "Game: None"
|
||||
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||
|
||||
MDButton:
|
||||
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||
on_press: app.export_options(self)
|
||||
theme_width: "Custom"
|
||||
size_hint_y: 1
|
||||
size_hint_x: 1
|
||||
|
||||
MDButtonText:
|
||||
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||
text: "Export Options"
|
||||
|
||||
MainLayout:
|
||||
cols: 1
|
||||
id: options
|
||||
2
data/sprites/custom/.gitignore
vendored
2
data/sprites/custom/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,7 +0,0 @@
|
||||
author: Nintendo
|
||||
data: null
|
||||
game: A Link to the Past
|
||||
min_format_version: 1
|
||||
name: Link
|
||||
format_version: 1
|
||||
sprite_version: 1
|
||||
61
deploy/docker-compose.yml
Normal file
61
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
services:
|
||||
multiworld:
|
||||
# Build only once. Web service uses the same image build
|
||||
build:
|
||||
context: ..
|
||||
# Name image for use in web service
|
||||
image: archipelago-base
|
||||
# Use locally-built image
|
||||
pull_policy: never
|
||||
# Launch main process without website hosting (config override)
|
||||
entrypoint: python WebHost.py --config_override selflaunch.yaml
|
||||
volumes:
|
||||
# Mount application volume
|
||||
- app_volume:/app
|
||||
|
||||
# Mount configs
|
||||
- ./example_config.yaml:/app/config.yaml
|
||||
- ./example_selflaunch.yaml:/app/selflaunch.yaml
|
||||
|
||||
# Expose on host network for access to dynamically mapped ports
|
||||
network_mode: host
|
||||
|
||||
# No Healthcheck in place yet for multiworld
|
||||
healthcheck:
|
||||
test: ["NONE"]
|
||||
web:
|
||||
# Use image build by multiworld service
|
||||
image: archipelago-base
|
||||
# Use locally-built image
|
||||
pull_policy: never
|
||||
# Launch gunicorn targeting WebHost application
|
||||
entrypoint: gunicorn -c gunicorn.conf.py
|
||||
volumes:
|
||||
# Mount application volume
|
||||
- app_volume:/app
|
||||
|
||||
# Mount configs
|
||||
- ./example_config.yaml:/app/config.yaml
|
||||
- ./example_gunicorn.conf.py:/app/gunicorn.conf.py
|
||||
environment:
|
||||
# Bind gunicorn on 8000
|
||||
- PORT=8000
|
||||
|
||||
nginx:
|
||||
image: nginx:stable-alpine
|
||||
volumes:
|
||||
# Mount application volume
|
||||
- app_volume:/app
|
||||
|
||||
# Mount config
|
||||
- ./example_nginx.conf:/etc/nginx/nginx.conf
|
||||
ports:
|
||||
# Nginx listening internally on port 80 -- mapped to 8080 on host
|
||||
- 8080:80
|
||||
depends_on:
|
||||
- web
|
||||
|
||||
volumes:
|
||||
# Share application directory amongst multiworld and web services
|
||||
# (for access to log files and the like), and nginx (for static files)
|
||||
app_volume:
|
||||
14
deploy/example_config.yaml
Normal file
14
deploy/example_config.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
# Refer to ../docs/webhost configuration sample.yaml
|
||||
|
||||
# We'll be hosting VIA gunicorn
|
||||
SELFHOST: false
|
||||
# We'll start a separate process for rooms and generators
|
||||
SELFLAUNCH: false
|
||||
|
||||
# Host Address. This is the address encoded into the patch that will be used for client auto-connect.
|
||||
# Set as your local IP (192.168.x.x) to serve over LAN.
|
||||
HOST_ADDRESS: localhost
|
||||
|
||||
# Asset redistribution rights. If true, the host affirms they have been given explicit permission to redistribute
|
||||
# the proprietary assets in WebHostLib
|
||||
#ASSET_RIGHTS: false
|
||||
19
deploy/example_gunicorn.conf.py
Normal file
19
deploy/example_gunicorn.conf.py
Normal file
@@ -0,0 +1,19 @@
|
||||
workers = 2
|
||||
threads = 2
|
||||
wsgi_app = "WebHost:get_app()"
|
||||
accesslog = "-"
|
||||
access_log_format = (
|
||||
'%({x-forwarded-for}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
|
||||
)
|
||||
worker_class = "gthread" # "sync" | "gthread"
|
||||
forwarded_allow_ips = "*"
|
||||
loglevel = "info"
|
||||
|
||||
"""
|
||||
You can programatically set values.
|
||||
For example, set number of workers to half of the cpu count:
|
||||
|
||||
import multiprocessing
|
||||
|
||||
workers = multiprocessing.cpu_count() / 2
|
||||
"""
|
||||
64
deploy/example_nginx.conf
Normal file
64
deploy/example_nginx.conf
Normal file
@@ -0,0 +1,64 @@
|
||||
worker_processes 1;
|
||||
|
||||
user nobody nogroup;
|
||||
# 'user nobody nobody;' for systems with 'nobody' as a group instead
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024; # increase if you have lots of clients
|
||||
accept_mutex off; # set to 'on' if nginx worker_processes > 1
|
||||
# 'use epoll;' to enable for Linux 2.6+
|
||||
# 'use kqueue;' to enable for FreeBSD, OSX
|
||||
use epoll;
|
||||
}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
# fallback in case we can't determine a type
|
||||
default_type application/octet-stream;
|
||||
access_log /var/log/nginx/access.log combined;
|
||||
sendfile on;
|
||||
|
||||
upstream app_server {
|
||||
# fail_timeout=0 means we always retry an upstream even if it failed
|
||||
# to return a good HTTP response
|
||||
|
||||
# for UNIX domain socket setups
|
||||
# server unix:/tmp/gunicorn.sock fail_timeout=0;
|
||||
|
||||
# for a TCP configuration
|
||||
server web:8000 fail_timeout=0;
|
||||
}
|
||||
|
||||
server {
|
||||
# use 'listen 80 deferred;' for Linux
|
||||
# use 'listen 80 accept_filter=httpready;' for FreeBSD
|
||||
listen 80 deferred;
|
||||
client_max_body_size 4G;
|
||||
|
||||
# set the correct host(s) for your site
|
||||
# server_name example.com www.example.com;
|
||||
|
||||
keepalive_timeout 5;
|
||||
|
||||
# path for static files
|
||||
root /app/WebHostLib;
|
||||
|
||||
location / {
|
||||
# checks for static file, if not found proxy to app
|
||||
try_files $uri @proxy_to_app;
|
||||
}
|
||||
|
||||
location @proxy_to_app {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
# we don't want nginx trying to do something clever with
|
||||
# redirects, we set the Host: header above already.
|
||||
proxy_redirect off;
|
||||
|
||||
proxy_pass http://app_server;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
deploy/example_selflaunch.yaml
Normal file
13
deploy/example_selflaunch.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
# Refer to ../docs/webhost configuration sample.yaml
|
||||
|
||||
# We'll be hosting VIA gunicorn
|
||||
SELFHOST: false
|
||||
# Start room and generator processes
|
||||
SELFLAUNCH: true
|
||||
JOB_THRESHOLD: 0
|
||||
|
||||
# Maximum concurrent world gens
|
||||
GENERATORS: 3
|
||||
|
||||
# Rooms will be spread across multiple processes
|
||||
HOSTERS: 4
|
||||
@@ -15,15 +15,16 @@
|
||||
# A Link to the Past
|
||||
/worlds/alttp/ @Berserker66
|
||||
|
||||
# APQuest
|
||||
# NewSoupVi is acting maintainer, but world belongs to core with the exception of the music
|
||||
/worlds/apquest/ @NewSoupVi
|
||||
|
||||
# Sudoku (APSudoku)
|
||||
/worlds/apsudoku/ @EmilyV99
|
||||
|
||||
# Aquaria
|
||||
/worlds/aquaria/ @tioui
|
||||
|
||||
# ArchipIDLE
|
||||
/worlds/archipidle/ @LegendaryLinux
|
||||
|
||||
# Blasphemous
|
||||
/worlds/blasphemous/ @TRPG0
|
||||
|
||||
@@ -42,15 +43,18 @@
|
||||
# Celeste 64
|
||||
/worlds/celeste64/ @PoryGone
|
||||
|
||||
# Celeste (Open World)
|
||||
/worlds/celeste_open_world/ @PoryGone
|
||||
|
||||
# ChecksFinder
|
||||
/worlds/checksfinder/ @SunCatMC
|
||||
|
||||
# Choo-Choo Charles
|
||||
/worlds/cccharles/ @Yaranorgoth
|
||||
|
||||
# Civilization VI
|
||||
/worlds/civ6/ @hesto2
|
||||
|
||||
# Clique
|
||||
/worlds/clique/ @ThePhar
|
||||
|
||||
# Dark Souls III
|
||||
/worlds/dark_souls_3/ @Marechal-L @nex3
|
||||
|
||||
@@ -66,12 +70,18 @@
|
||||
# DOOM II
|
||||
/worlds/doom_ii/ @Daivuk @KScl
|
||||
|
||||
# EarthBound
|
||||
/worlds/earthbound/ @PinkSwitch
|
||||
|
||||
# Factorio
|
||||
/worlds/factorio/ @Berserker66
|
||||
|
||||
# Faxanadu
|
||||
/worlds/faxanadu/ @Daivuk
|
||||
|
||||
# Final Fantasy (1)
|
||||
/worlds/ff1/ @Rosalie-A
|
||||
|
||||
# Final Fantasy Mystic Quest
|
||||
/worlds/ffmq/ @Alchav @wildham0
|
||||
|
||||
@@ -139,6 +149,9 @@
|
||||
# Overcooked! 2
|
||||
/worlds/overcooked2/ @toasterparty
|
||||
|
||||
# Paint
|
||||
/worlds/paint/ @MarioManTAW
|
||||
|
||||
# Pokemon Emerald
|
||||
/worlds/pokemon_emerald/ @Zunawe
|
||||
|
||||
@@ -148,9 +161,6 @@
|
||||
# Raft
|
||||
/worlds/raft/ @SunnyBat
|
||||
|
||||
# Rogue Legacy
|
||||
/worlds/rogue_legacy/ @ThePhar
|
||||
|
||||
# Risk of Rain 2
|
||||
/worlds/ror2/ @kindasneaki
|
||||
|
||||
@@ -169,8 +179,12 @@
|
||||
# Sonic Adventure 2 Battle
|
||||
/worlds/sa2b/ @PoryGone @RaspberrySpace
|
||||
|
||||
# Satisfactory
|
||||
/worlds/satisfactory/ @Jarno458 @budak7273
|
||||
|
||||
# Starcraft 2
|
||||
/worlds/sc2/ @Ziktofel
|
||||
# Note: @Ziktofel acts as a mentor
|
||||
/worlds/sc2/ @MatthewMarinets @Snarkie @SirChuckOfTheChuckles
|
||||
|
||||
# Super Metroid
|
||||
/worlds/sm/ @lordlou
|
||||
@@ -203,7 +217,7 @@
|
||||
/worlds/timespinner/ @Jarno458
|
||||
|
||||
# The Legend of Zelda (1)
|
||||
/worlds/tloz/ @Rosalie-A @t3hf1gm3nt
|
||||
/worlds/tloz/ @Rosalie-A
|
||||
|
||||
# TUNIC
|
||||
/worlds/tunic/ @silent-destroyer @ScipioWright
|
||||
@@ -241,9 +255,6 @@
|
||||
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for
|
||||
# any of these worlds, please review `/docs/world maintainer.md` documentation.
|
||||
|
||||
# Final Fantasy (1)
|
||||
# /worlds/ff1/
|
||||
|
||||
# Ocarina of Time
|
||||
# /worlds/oot/
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ it will not be detailed here.
|
||||
The client is an intermediary program between the game and the Archipelago server. This can either be a direct
|
||||
modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it
|
||||
must fulfill a few requirements in order to function as expected. Libraries for most modern languages and the spec for
|
||||
various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document.
|
||||
various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document. Additional help with specific game
|
||||
engines and rom formats can be found in the #ap-modding-help channel in the [Discord](https://archipelago.gg/discord).
|
||||
|
||||
### Hard Requirements
|
||||
|
||||
@@ -62,6 +63,24 @@ if possible.
|
||||
* If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from
|
||||
other clients. The icon size is 48x48 pixels, but smaller or larger images will scale to that size.
|
||||
|
||||
### Launcher Integration
|
||||
|
||||
If you have a python client or want to utilize the integration features of the Archipelago Launcher (ex. Slot links in
|
||||
webhost) you can define a Component to be a part of the Launcher. `LauncherComponents.components` can be appended to
|
||||
with additional Components in order to automatically add them to the Launcher. Most Components only need a
|
||||
`display_name` and `func`, but `supports_uri` and `game_name` can be defined to support launching by webhost links,
|
||||
`icon` and `description` can be used to customize display in the Launcher UI, and `file_identifier` can be used to
|
||||
launch by file.
|
||||
|
||||
Additionally, if you use `func` you have access to LauncherComponent.launch or launch_subprocess to run your
|
||||
function as a subprocesses that can be utilized side by side other clients.
|
||||
```py
|
||||
def my_func(*args: str):
|
||||
from .client import run_client
|
||||
LauncherComponent.launch(run_client, name="My Client", args=args)
|
||||
```
|
||||
|
||||
|
||||
## World
|
||||
|
||||
The world is your game integration for the Archipelago generator, webhost, and multiworld server. It contains all the
|
||||
@@ -121,8 +140,8 @@ if possible.
|
||||
|
||||
* An implementation of
|
||||
[get_filler_item_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L473)
|
||||
* By default, this function chooses any item name from `item_name_to_id`, so you want to limit it to only the true
|
||||
filler items.
|
||||
* By default, this function chooses any item name from `item_name_to_id`, which may include items you consider
|
||||
"non-repeatable".
|
||||
* An `options_dataclass` defining the options players have available to them
|
||||
* This should be accompanied by a type hint for `options` with the same class name
|
||||
* A [bug report page](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L220)
|
||||
|
||||
@@ -1,35 +1,110 @@
|
||||
# apworld Specification
|
||||
# APWorld Specification
|
||||
|
||||
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
|
||||
Those are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
|
||||
These are called "APWorlds".
|
||||
They are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
|
||||
See [world api.md](world%20api.md) for details.
|
||||
APWorlds can either be a folder, or they can be packaged as an .apworld file.
|
||||
|
||||
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld`
|
||||
file into the worlds folder.
|
||||
## .apworld File Format
|
||||
|
||||
**Warning:** apworlds have to be all lower case, otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
|
||||
The `.apworld` file format provides a way to package and ship an APWorld that is not part of the main distribution
|
||||
by placing a `*.apworld` file into the worlds folder.
|
||||
|
||||
|
||||
## File Format
|
||||
|
||||
apworld files are zip archives, all lower case, with the file ending `.apworld`.
|
||||
`.apworld` files are zip archives, all lower case, with the file ending `.apworld`.
|
||||
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
|
||||
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
|
||||
|
||||
**Warning:** `.apworld` files have to be all lower case,
|
||||
otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
|
||||
|
||||
## Metadata
|
||||
|
||||
No metadata is specified yet.
|
||||
Metadata about the APWorld is defined in an `archipelago.json` file.
|
||||
|
||||
If the APWorld is a folder, the only required field is "game":
|
||||
```json
|
||||
{
|
||||
"game": "Game Name"
|
||||
}
|
||||
```
|
||||
|
||||
## Extra Data
|
||||
There are also the following optional fields:
|
||||
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
|
||||
Archipelago version respectively to filter those files from being loaded.
|
||||
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
|
||||
An APWorld without a world_version is always treated as older than one with a version
|
||||
(**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`)
|
||||
* `authors` - a list of authors, to eventually be displayed in various user-facing places such as WebHost and
|
||||
package managers. Should always be a list of strings.
|
||||
|
||||
The zip can contain arbitrary files in addition what was specified above.
|
||||
If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`,
|
||||
which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
|
||||
These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the
|
||||
["Build APWorlds" launcher component](#build-apworlds-launcher-component),
|
||||
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
|
||||
|
||||
### "Build APWorlds" Launcher Component
|
||||
|
||||
In the Archipelago Launcher, there is a "Build APWorlds" component that will package all world folders to `.apworld`,
|
||||
and add `archipelago.json` manifest files to them.
|
||||
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
|
||||
The `archipelago.json` file in each .apworld will automatically include the appropriate
|
||||
`version` and `compatible_version`.
|
||||
The component can also be called from the command line to allow for specifying a certain list of worlds to build.
|
||||
For example, running `Launcher.py "Build APWorlds" -- "Game Name"` will build only the game called `Game Name`.
|
||||
|
||||
If a world folder has an `archipelago.json` in its root, any fields it contains will be carried over.
|
||||
So, a world folder with an `archipelago.json` that looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"game": "Game Name",
|
||||
"minimum_ap_version": "0.6.4",
|
||||
"world_version": "2.1.4",
|
||||
"authors": ["NewSoupVi"]
|
||||
}
|
||||
```
|
||||
|
||||
will be packaged into an `.apworld` with a manifest file inside of it that looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"minimum_ap_version": "0.6.4",
|
||||
"world_version": "2.1.4",
|
||||
"authors": ["NewSoupVi"],
|
||||
"version": 7,
|
||||
"compatible_version": 7,
|
||||
"game": "Game Name"
|
||||
}
|
||||
```
|
||||
|
||||
This is the recommended workflow for packaging your world to an `.apworld`.
|
||||
|
||||
### .apignore Exclusions
|
||||
|
||||
By default, any additional files inside of the world folder will be packaged into the resulting `.apworld` archive and
|
||||
can then be read by the world. However, if there are any other files that aren't needed in the resulting `.apworld`, you
|
||||
can automatically prevent the build component from including them by specifying them in a file called `.apignore` inside
|
||||
the root of the world folder.
|
||||
|
||||
The `.apignore` file selects files in the same way as the `.gitignore` format with patterns separated by line describing
|
||||
which files to ignore. For example, an `.apignore` like this:
|
||||
|
||||
```gitignore
|
||||
*.iso
|
||||
scripts/
|
||||
!scripts/needed.py
|
||||
```
|
||||
|
||||
would ignore any `.iso` files and anything in the scripts folder except for `scripts/needed.py`.
|
||||
|
||||
Some exclusions are made by default for all worlds such as `__pycache__` folders. These are listed in the
|
||||
`GLOBAL.apignore` file inside of the `data` directory.
|
||||
|
||||
## Caveats
|
||||
|
||||
Imports from other files inside the apworld have to use relative imports. e.g. `from .options import MyGameOptions`
|
||||
Imports from other files inside the APWorld have to use relative imports. e.g. `from .options import MyGameOptions`
|
||||
|
||||
Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or
|
||||
`from worlds.AutoWorld import World`
|
||||
|
||||
@@ -6,6 +6,49 @@ including [Contributing](contributing.md), [Adding Games](<adding games.md>), an
|
||||
|
||||
---
|
||||
|
||||
### I've never added a game to Archipelago before. Should I start with the APWorld or the game client?
|
||||
|
||||
Strictly speaking, this is a false dichotomy: we do *not* recommend doing 100% of client work before the APWorld,
|
||||
or 100% of APWorld work before the client. It's important to iterate on both parts and test them together.
|
||||
However, the early iterations tend to be very similar for most games,
|
||||
so the typical recommendation for first-time AP developers is:
|
||||
|
||||
- Start with a proof-of-concept for [the game client](adding%20games.md#client)
|
||||
- Figure out how to interface with the game. Whether that means "modding" the game, or patching a ROM file,
|
||||
or developing a separate client program that edits the game's memory, or some other technique.
|
||||
- Figure out how to give items and detect locations in the actual game. Not every item and location,
|
||||
just one of each major type (e.g. opening a chest vs completing a sidequest) to prove all the items and locations
|
||||
you want can actually be implemented.
|
||||
- Figure out how to make a websocket connection to an AP server, possibly using a client library (see [Network Protocol](<network%20protocol.md>).
|
||||
To make absolutely sure this part works, you may want to test the connection by generating a multiworld
|
||||
with a different game, then making your client temporarily pretend to be that other game.
|
||||
- Next, make a "trivial" APWorld, i.e. an APWorld that always generates the same items and locations
|
||||
- If you've never done this before, likely the fastest approach is to copy-paste [APQuest](<../worlds/apquest>), and read the many
|
||||
comments in there until you understand how to edit the items and locations.
|
||||
- Then you can do your first "end-to-end test": generate a multiworld using your APWorld, [run a local server](<running%20from%20source.md>)
|
||||
to host it, connect to that local server from your game client, actually check a location in the game,
|
||||
and finally make sure the client successfully sent that location check to the AP server
|
||||
as well as received an item from it.
|
||||
|
||||
That's about where general recommendations end. What you should do next will depend entirely on your game
|
||||
(e.g. implement more items, write down logic rules, add client features, prototype a tracker, etc).
|
||||
If you're not sure, then this would be a good time to re-read [Adding Games](<adding%20games.md>), and [World API](<world%20api.md>).
|
||||
|
||||
There are a few assumptions in this recommendation worth stating explicitly, namely:
|
||||
|
||||
- If something you want to do is infeasible, you want to find out that it's infeasible as soon as possible, before
|
||||
you write a bunch of code assuming it could be done. That's why we recommend starting with the game client.
|
||||
- Getting an APWorld to generate whatever items/locations you want is always feasible, since items/locations are
|
||||
little more than id numbers and name strings during generation.
|
||||
- You generally want to get to an "end-to-end playable" prototype quickly. On top of all the technical challenges these
|
||||
docs describe, it's also important to check that a randomizer is *fun to play*, and figure out what features would be
|
||||
essential for a public release.
|
||||
- A first-time world developer may or may not be deeply familiar with Archipelago, but they're almost certainly familiar
|
||||
with the game they want to randomize. So judging whether your game client is working correctly might be significantly
|
||||
easier than judging if your APWorld is working.
|
||||
|
||||
---
|
||||
|
||||
### My game has a restrictive start that leads to fill errors
|
||||
|
||||
A "restrictive start" here means having a combination of very few sphere 1 locations and potentially requiring more
|
||||
@@ -140,3 +183,58 @@ So when the game itself does not follow this assumption, the options are:
|
||||
- For connections, any logical regions will still need to be reachable through other, *repeatable* connections
|
||||
- For locations, this may require game changes to remove the vanilla item if it affects logic
|
||||
- Decide that resetting the save file is part of the game's logic, and warn players about that
|
||||
|
||||
---
|
||||
|
||||
### What are "local" vs "remote" items, and what are the pros and cons of each?
|
||||
|
||||
First off, these terms can be misleading. Since the whole point of a multi-game multiworld randomizer is that some items
|
||||
are going to be placed in other slots (unless there's only one slot), the choice isn't really "local vs remote";
|
||||
it's "mixed local/remote vs all remote". You have to get "remote items" working to be an AP implementation at all, and
|
||||
it's often simpler to handle every item/location the same way, so you generally shouldn't worry about "local items"
|
||||
until you've finished more critical features.
|
||||
|
||||
Next, "local" and "remote" items confusingly refer to multiple concepts, so it's important to clearly separate them:
|
||||
|
||||
- Whether an item happens to get placed in the same slot it originates from, or a different slot. I'll call these
|
||||
"locally placed" and "remotely placed" items.
|
||||
- Whether an AP client implements location checking for locally placed items by skipping the usual AP server roundtrip
|
||||
(i.e. sending [LocationChecks](<network%20protocol.md#locationchecks>)
|
||||
then receiving [ReceivedItems](<network%20protocol.md#receiveditems>)
|
||||
) and directly giving the item to the player, or by doing the AP server roundtrip regardless. I'll call these
|
||||
"locally implemented" items and "remotely implemented" items.
|
||||
- Locally implementing items requires the AP client to know what the locally placed items were without asking an AP
|
||||
server (or else you'd effectively be doing remote items with extra steps). Typically, it gets that information from
|
||||
a patch file, which is one reason why games that already need a patch file are more likely to choose local items.
|
||||
- If items are remotely implemented, the AP client can use [location scouts](<network%20protocol.md#LocationScouts>)
|
||||
to learn what items are placed on what locations. Features that require this information are sometimes mistakenly
|
||||
assumed to require locally implemented items, but location scouts work just as well as patch file data.
|
||||
- [The `items_handling` bitflags in the Connect packet](<network%20protocol.md#items_handling-flags>).
|
||||
AP clients with remotely implemented items will typically set all three flags, including "from your own world".
|
||||
Clients with locally implemented items might set only the "from other worlds" flag.
|
||||
- Whether a local items client sets the "starting inventory" flag likely depends on other details. For example, if a ROM
|
||||
is being patched, and starting inventory can be added to that patch, then it makes sense to leave the flag unset.
|
||||
|
||||
When people talk about "local vs remote items" as a choice that world devs have to make, they mean deciding whether
|
||||
your client will locally or remotely implement the items which happen to be locally placed (or make both
|
||||
implementations, or let the player choose an implementation).
|
||||
|
||||
Theoretically, the biggest benefit of "local items" is that it allows a solo (single slot) multiworld to be played
|
||||
entirely offline, with no AP server, from start to finish. This is similar to a "standalone"/non-AP randomizer,
|
||||
except that you still get AP's player options, generation, etc. for free.
|
||||
For some games, there are also technical constraints that make certain items easier to implement locally,
|
||||
or less glitchy when implemented locally, as long as you're okay with never allowing these items to be placed remotely
|
||||
(or offering the player even more options).
|
||||
|
||||
The main downside (besides more implementation work) is that "local items" can't support "same slot co-op".
|
||||
That's when two players on two different machines connect to the same slot and play together.
|
||||
This only works if both players receive all the items for that slot, including ones found by the other player,
|
||||
which requires those items to be implemented remotely so the AP server can send them to all of that slot's clients.
|
||||
|
||||
So to recap:
|
||||
|
||||
- (All) remote items is often the simplest choice, since you have to implement remote items anyway.
|
||||
- Remote items enable same slot co-op.
|
||||
- Local items enable solo offline play.
|
||||
- If you want to support both solo offline play and same slot co-op,
|
||||
you might need to expose local vs remote items as an option to the player.
|
||||
|
||||
@@ -16,11 +16,11 @@ game contributions:
|
||||
* **Do not introduce unit test failures/regressions.**
|
||||
Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test
|
||||
your changes. Currently, the oldest supported version
|
||||
is [Python 3.10](https://www.python.org/downloads/release/python-31015/).
|
||||
is [Python 3.11](https://www.python.org/downloads/release/python-31113/).
|
||||
It is recommended that automated github actions are turned on in your fork to have github run unit tests after
|
||||
pushing.
|
||||
You can turn them on here:
|
||||

|
||||

|
||||
|
||||
* **When reviewing PRs, please leave a message about what was done.**
|
||||
We don't have full test coverage, so manual testing can help.
|
||||
|
||||
92
docs/deploy using containers.md
Normal file
92
docs/deploy using containers.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Deploy Using Containers
|
||||
|
||||
If you just want to play and there is a compiled version available on the [Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases), use that version.
|
||||
To build the full Archipelago software stack, refer to [Running From Source](running%20from%20source.md).
|
||||
Follow these steps to build and deploy a containerized instance of the web host software, optionally integrating [Gunicorn](https://gunicorn.org/) WSGI HTTP Server running behind the [nginx](https://nginx.org/) reverse proxy.
|
||||
|
||||
|
||||
## Building the Container Image
|
||||
|
||||
What you'll need:
|
||||
* A container runtime engine such as:
|
||||
* [Docker](https://www.docker.com/) (Version 23.0 or later)
|
||||
* [Podman](https://podman.io/) (version 4.0 or later)
|
||||
* For running with rootless podman, you need to ensure all ports used are usable rootless, by default ports less than 1024 are root only. See [the official tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md) for details.
|
||||
* The Docker Buildx plugin (for Docker), as the Dockerfile uses `$TARGETARCH` for architecture detection. Follow [Docker's guide](https://docs.docker.com/build/buildx/install/). Verify with `docker buildx version`.
|
||||
|
||||
Starting from the root repository directory, the standalone Archipelago image can be built and run with the command:
|
||||
`docker build -t archipelago .`
|
||||
Or:
|
||||
`podman build -t archipelago .`
|
||||
|
||||
It is recommended to tag the image using `-t` to more easily identify the image and run it.
|
||||
|
||||
|
||||
## Running the Container
|
||||
|
||||
Running the container can be performed using:
|
||||
`docker run --network host archipelago`
|
||||
Or:
|
||||
`podman run --network host archipelago`
|
||||
|
||||
The Archipelago web host requires access to multiple ports in order to host game servers simultaneously. To simplify configuration for this purpose, specify `--network host`.
|
||||
|
||||
Given the default configuration, the website will be accessible at the hostname/IP address (localhost if run locally) of the machine being deployed to, at port 80. It can be configured by creating a YAML file and mapping a volume to the container when running initially:
|
||||
`docker run archipelago --network host -v /path/to/config.yaml:/app/config.yaml`
|
||||
See `docs/webhost configuration sample.yaml` for example.
|
||||
|
||||
|
||||
## Using Docker Compose
|
||||
|
||||
An example [docker compose](../deploy/docker-compose.yml) file can be found in [deploy](../deploy), along with example configuration files used by the services it orchestrates. Using these files as-is will spin up two separate archipelago containers with special modifications to their runtime arguments, in addition to deploying an `nginx` reverse proxy container.
|
||||
|
||||
To deploy in this manner, from the ["deploy"](../deploy) directory, run:
|
||||
`docker compose up -d`
|
||||
|
||||
### Services
|
||||
|
||||
The `docker-compose.yaml` file defines three services:
|
||||
* multiworld:
|
||||
* Executes the main `WebHost` process, using the [example config](../deploy/example_config.yaml), and overriding with a secondary [selflaunch example config](../deploy/example_selflaunch.yaml). This is because we do not want to launch the website through this service.
|
||||
* web:
|
||||
* Executes `gunicorn` using its [example config](../deploy/example_gunicorn.conf.py), which will bind it to the `WebHost` application, in effect launching it.
|
||||
* We mount the main [config](../deploy/example_config.yaml) without an override to specify that we are launching the website through this service.
|
||||
* No ports are exposed through to the host.
|
||||
* nginx:
|
||||
* Serves as a reverse proxy with `web` as its upstream.
|
||||
* Directs all HTTP traffic from port 80 to the upstream service.
|
||||
* Exposed to the host on port 8080. This is where we can reach the website.
|
||||
|
||||
### Configuration
|
||||
|
||||
As these are examples, they can be copied and modified. For instance setting the value of `HOST_ADDRESS` in [example config](../deploy/example_config.yaml) to host machines local IP address, will expose the service to its local area network.
|
||||
|
||||
The configuration files may be modified to handle for machine-specific optimizations, such as:
|
||||
* Web pages responding too slowly
|
||||
* Edit [the gunicorn config](../deploy/example_gunicorn.conf.py) to increase thread and/or worker count.
|
||||
* Game generation stalls
|
||||
* Increase the generator count in [selflaunch config](../deploy/example_selflaunch.yaml)
|
||||
* Gameplay lags
|
||||
* Increase the hoster count in [selflaunch config](../deploy/example_selflaunch.yaml)
|
||||
|
||||
Changes made to `docker-compose.yaml` can be applied by running `docker compose up -d`, while those made to other files are applied by running `docker compose restart`.
|
||||
|
||||
|
||||
## Windows
|
||||
|
||||
It is possible to carry out these deployment steps on Windows under [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/install).
|
||||
|
||||
|
||||
## Optional: A Link to the Past Enemizer
|
||||
|
||||
Only required to generate seeds that include A Link to the Past with certain options enabled. You will receive an
|
||||
error if it is required.
|
||||
Enemizer can be enabled on `x86_64` platform architecture, and is included in the image build process. Enemizer requires a version 1.0 Japanese "Zelda no Densetsu" `.sfc` rom file to be placed in the application directory:
|
||||
`docker run archipelago -v "/path/to/zelda.sfc:/app/Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"`.
|
||||
Enemizer is not currently available for `aarch64`.
|
||||
|
||||
|
||||
## Optional: Git
|
||||
|
||||
Building the image requires a local copy of the ArchipelagoMW source code.
|
||||
Refer to [Running From Source](running%20from%20source.md#optional-git).
|
||||
@@ -352,14 +352,14 @@ direction_matching_group_lookup = {
|
||||
|
||||
Terrain matching or dungeon shuffle:
|
||||
```python
|
||||
def randomize_within_same_group(group: int) -> List[int]:
|
||||
def randomize_within_same_group(group: int) -> list[int]:
|
||||
return [group]
|
||||
identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group)
|
||||
```
|
||||
|
||||
Directional + area shuffle:
|
||||
```python
|
||||
def get_target_groups(group: int) -> List[int]:
|
||||
def get_target_groups(group: int) -> list[int]:
|
||||
# example group: LEFT | CAVE
|
||||
# example result: [RIGHT | CAVE, DOOR | CAVE]
|
||||
direction = group & Groups.DIRECTION_MASK
|
||||
|
||||
@@ -125,10 +125,8 @@ flowchart LR
|
||||
NM[Mod with Archipelago.MultiClient.Net]
|
||||
subgraph FNA/XNA
|
||||
TS[Timespinner]
|
||||
RL[Rogue Legacy]
|
||||
end
|
||||
NM <-- TsRandomizer --> TS
|
||||
NM <-- RogueLegacyRandomizer --> RL
|
||||
subgraph Unity
|
||||
ROR[Risk of Rain 2]
|
||||
SN[Subnautica]
|
||||
|
||||
@@ -79,7 +79,7 @@ Sent to clients when they connect to an Archipelago server.
|
||||
| generator_version | [NetworkVersion](#NetworkVersion) | Object denoting the version of Archipelago which generated the multiworld. |
|
||||
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` |
|
||||
| password | bool | Denoted whether a password is required to join this room. |
|
||||
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
|
||||
| permissions | dict\[str, [Permission](#Permission)\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
|
||||
| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. |
|
||||
| location_check_points | int | The amount of hint points you receive per item/location check completed. |
|
||||
| games | list\[str\] | List of games present in this multiworld. |
|
||||
@@ -225,7 +225,7 @@ Sent to clients after a client requested this message be sent to them, more info
|
||||
| games | list\[str\] | Optional. Game names this message is targeting |
|
||||
| slots | list\[int\] | Optional. Player slot IDs that this message is targeting |
|
||||
| tags | list\[str\] | Optional. Client [Tags](#Tags) this message is targeting |
|
||||
| data | dict | The data in the [Bounce](#Bounce) package copied |
|
||||
| data | dict | Optional. The data in the [Bounce](#Bounce) package copied |
|
||||
|
||||
### InvalidPacket
|
||||
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
|
||||
@@ -276,6 +276,7 @@ These packets are sent purely from client to server. They are not accepted by cl
|
||||
* [Sync](#Sync)
|
||||
* [LocationChecks](#LocationChecks)
|
||||
* [LocationScouts](#LocationScouts)
|
||||
* [CreateHints](#CreateHints)
|
||||
* [UpdateHint](#UpdateHint)
|
||||
* [StatusUpdate](#StatusUpdate)
|
||||
* [Say](#Say)
|
||||
@@ -294,7 +295,7 @@ Sent by the client to initiate a connection to an Archipelago game session.
|
||||
| password | str | If the game session requires a password, it should be passed here. |
|
||||
| game | str | The name of the game the client is playing. Example: `A Link to the Past` |
|
||||
| name | str | The player name for this client. |
|
||||
| uuid | str | Unique identifier for player client. |
|
||||
| uuid | str | Unique identifier for player. Cached in the user cache \Archipelago\Cache\common.json |
|
||||
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
|
||||
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
|
||||
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
|
||||
@@ -340,6 +341,7 @@ Fully remote clients without a patch file may use this to "place" items onto the
|
||||
|
||||
LocationScouts can also be used to inform the server of locations the client has seen, but not checked. This creates a hint as if the player had run `!hint_location` on a location, but without deducting hint points.
|
||||
This is useful in cases where an item appears in the game world, such as 'ledge items' in _A Link to the Past_. To do this, set the `create_as_hint` parameter to a non-zero value.
|
||||
Note that LocationScouts with a non-zero `create_as_hint` value will _always_ create a **persistent** hint (listed in the Hints tab of concerning players' TextClients), even if the location was already found. If this is not desired behavior, you need to prevent sending LocationScouts with `create_as_hint` for already found locations in your client-side code.
|
||||
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
@@ -347,6 +349,21 @@ This is useful in cases where an item appears in the game world, such as 'ledge
|
||||
| locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. |
|
||||
| create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. |
|
||||
|
||||
### CreateHints
|
||||
|
||||
Sent to the server to create hints for a specified list of locations.
|
||||
Hints that already exist will be silently skipped and their status will not be updated.
|
||||
|
||||
When creating hints for another slot's locations, the packet will fail if any of those locations don't contain items for the requesting slot.
|
||||
When creating hints for your own slot's locations, non-existing locations will silently be skipped.
|
||||
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| locations | list\[int\] | The ids of the locations to create hints for. |
|
||||
| player | int | The ID of the player whose locations are being hinted for. Defaults to the requesting slot. |
|
||||
| status | [HintStatus](#HintStatus) | If included, sets the status of the hint to this status. Defaults to `HINT_UNSPECIFIED`. Cannot set `HINT_FOUND`. |
|
||||
|
||||
### UpdateHint
|
||||
Sent to the server to update the status of a Hint. The client must be the 'receiving_player' of the Hint, or the update fails.
|
||||
|
||||
@@ -408,7 +425,7 @@ the server will forward the message to all those targets to which any one requir
|
||||
| games | list\[str\] | Optional. Game names that should receive this message |
|
||||
| slots | list\[int\] | Optional. Player IDs that should receive this message |
|
||||
| tags | list\[str\] | Optional. Client tags that should receive this message |
|
||||
| data | dict | Any data you want to send |
|
||||
| data | dict | Optional. Any data you want to send |
|
||||
|
||||
### Get
|
||||
Used to request a single or multiple values from the server's data storage, see the [Set](#Set) package for how to write values to the data storage. A Get package will be answered with a [Retrieved](#Retrieved) package.
|
||||
@@ -630,6 +647,16 @@ class Version(NamedTuple):
|
||||
build: int
|
||||
```
|
||||
|
||||
If constructing version information as a dict for a custom client rather than as a NamedTuple built into the CommonClient, you must add the `class` key to allow Archipelago to compare version support.
|
||||
```
|
||||
"version": {
|
||||
"class": "Version",
|
||||
"build": X,
|
||||
"major": Y,
|
||||
"minor": Z
|
||||
}
|
||||
```
|
||||
|
||||
### SlotType
|
||||
An enum representing the nature of a slot.
|
||||
|
||||
@@ -645,13 +672,14 @@ class SlotType(enum.IntFlag):
|
||||
An object representing static information about a slot.
|
||||
|
||||
```python
|
||||
import typing
|
||||
from collections.abc import Sequence
|
||||
from typing import NamedTuple
|
||||
from NetUtils import SlotType
|
||||
class NetworkSlot(typing.NamedTuple):
|
||||
class NetworkSlot(NamedTuple):
|
||||
name: str
|
||||
game: str
|
||||
type: SlotType
|
||||
group_members: typing.List[int] = [] # only populated if type == group
|
||||
group_members: Sequence[int] = [] # only populated if type == group
|
||||
```
|
||||
|
||||
### Permission
|
||||
@@ -669,8 +697,8 @@ class Permission(enum.IntEnum):
|
||||
### Hint
|
||||
An object representing a Hint.
|
||||
```python
|
||||
import typing
|
||||
class Hint(typing.NamedTuple):
|
||||
from typing import NamedTuple
|
||||
class Hint(NamedTuple):
|
||||
receiving_player: int
|
||||
finding_player: int
|
||||
location: int
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user