mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-23 13:43:20 -07:00
Compare commits
493 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9adca5b04b | ||
|
|
63f3512829 | ||
|
|
182d58e847 | ||
|
|
1b200fb20b | ||
|
|
8a091c9e02 | ||
|
|
c3c517a200 | ||
|
|
c5b404baa8 | ||
|
|
77cab13827 | ||
|
|
31b2eed1f9 | ||
|
|
e23720a977 | ||
|
|
90058ee175 | ||
|
|
5c6dbdd98f | ||
|
|
8c2d246a53 | ||
|
|
0d26b6426f | ||
|
|
b9fb5c8b44 | ||
|
|
e518e41f67 | ||
|
|
7a38e44e64 | ||
|
|
64d3c55d62 | ||
|
|
89be26a33a | ||
|
|
5b5e2c3567 | ||
|
|
ef59a5ee11 | ||
|
|
b0b3e3668f | ||
|
|
42ace29db4 | ||
|
|
03992c43d9 | ||
|
|
e342a20fde | ||
|
|
3c28db0800 | ||
|
|
8f88152532 | ||
|
|
7a1311984f | ||
|
|
a9f594d6b2 | ||
|
|
5f1835c546 | ||
|
|
2359cceb64 | ||
|
|
a0a1c5d4c0 | ||
|
|
14d65fdf28 | ||
|
|
5fd9570368 | ||
|
|
c753fbff2d | ||
|
|
cdf7165ab4 | ||
|
|
893acd2f02 | ||
|
|
34aaa44b1f | ||
|
|
f2461a2fea | ||
|
|
bb2ecb8a97 | ||
|
|
439be48f36 | ||
|
|
750c8a9810 | ||
|
|
e11b40c94b | ||
|
|
be51fb9ba9 | ||
|
|
e1fca86cf8 | ||
|
|
1fa342b085 | ||
|
|
d146d90131 | ||
|
|
d5bdac02b7 | ||
|
|
dfd7cbf0c5 | ||
|
|
88a4a589a0 | ||
|
|
bead81b64b | ||
|
|
16d5b453a7 | ||
|
|
48906de873 | ||
|
|
9a64b8c5ce | ||
|
|
6ba2b7f8c3 | ||
|
|
6f7ca082f2 | ||
|
|
eb09be3594 | ||
|
|
9d654b7e3b | ||
|
|
8f7fcd4889 | ||
|
|
b85887241f | ||
|
|
5110676c76 | ||
|
|
0020e6c3d3 | ||
|
|
6e6fd0e9bc | ||
|
|
85c26f9740 | ||
|
|
9057ce0ce3 | ||
|
|
378cc91a4d | ||
|
|
cdde38fdc9 | ||
|
|
c34c00baa4 | ||
|
|
9bd535752e | ||
|
|
ecb22642af | ||
|
|
17ccfdc266 | ||
|
|
4633f12972 | ||
|
|
1f6c99635e | ||
|
|
4e92cac171 | ||
|
|
3b88630b0d | ||
|
|
e6d2d8f455 | ||
|
|
84c2d70d9a | ||
|
|
d408f7cabc | ||
|
|
72ae076ce7 | ||
|
|
277f21db7a | ||
|
|
9edd55961f | ||
|
|
9ad6959559 | ||
|
|
37a9d94865 | ||
|
|
e8f5bc1c96 | ||
|
|
8bb236411d | ||
|
|
332f955159 | ||
|
|
e7131eddc2 | ||
|
|
8c07a2c930 | ||
|
|
2fe51d087f | ||
|
|
b1f729a970 | ||
|
|
754e0a0de4 | ||
|
|
7abe7fe304 | ||
|
|
8a552e3639 | ||
|
|
743501addc | ||
|
|
6125e59ce3 | ||
|
|
1d8a0b2940 | ||
|
|
2a0ed7faa2 | ||
|
|
ad17c7fd21 | ||
|
|
4d17366662 | ||
|
|
5e2702090c | ||
|
|
f8d1e4edf3 | ||
|
|
04a3f78605 | ||
|
|
ea1e074083 | ||
|
|
199a6df65e | ||
|
|
c9ebf69e0d | ||
|
|
a36e6259f1 | ||
|
|
de4014f02c | ||
|
|
774457b362 | ||
|
|
7a8048a8fd | ||
|
|
fa49fef695 | ||
|
|
faac2540bf | ||
|
|
4e1eb78163 | ||
|
|
46829487d6 | ||
|
|
8fd021e757 | ||
|
|
a3af953683 | ||
|
|
f27da5cc78 | ||
|
|
23f0b720de | ||
|
|
f66d8e9a61 | ||
|
|
8499c2fd24 | ||
|
|
ea4c4dcc0c | ||
|
|
88e8e2408b | ||
|
|
e5815ae5a2 | ||
|
|
387f79ceae | ||
|
|
bae1259aba | ||
|
|
4ac1d91c16 | ||
|
|
81b8f3fc0e | ||
|
|
8541c87c97 | ||
|
|
0e4314ad1e | ||
|
|
6b44f217a3 | ||
|
|
76760e1bf3 | ||
|
|
d313a74266 | ||
|
|
a535ca31a8 | ||
|
|
da0bb80fb4 | ||
|
|
fb9026d12d | ||
|
|
4ae36ac727 | ||
|
|
ffab3a43fc | ||
|
|
e38d04c655 | ||
|
|
1923d6b1bc | ||
|
|
608a38f873 | ||
|
|
604ab79af9 | ||
|
|
4a43a6ae13 | ||
|
|
e9e0861eb7 | ||
|
|
477028a025 | ||
|
|
b90dcfb041 | ||
|
|
1790a389c7 | ||
|
|
deed9de3e7 | ||
|
|
9e748332dc | ||
|
|
749c2435ed | ||
|
|
6360609980 | ||
|
|
fed60ca61a | ||
|
|
f18f9e2dce | ||
|
|
e1b26bc76f | ||
|
|
2aada8f683 | ||
|
|
f9f386fa19 | ||
|
|
507a9a53ef | ||
|
|
c1ae637fa7 | ||
|
|
f967444ac2 | ||
|
|
c879307b8e | ||
|
|
c8ca3e643d | ||
|
|
9a648efa70 | ||
|
|
f45410c917 | ||
|
|
ec3f168a09 | ||
|
|
a9b35de7ee | ||
|
|
125d053b61 | ||
|
|
585cbf95a6 | ||
|
|
909565e5d9 | ||
|
|
a79423534c | ||
|
|
7a6fb5e35b | ||
|
|
6af34b66fb | ||
|
|
2974f7d11f | ||
|
|
edc0c89753 | ||
|
|
b1ff55dd06 | ||
|
|
f4b5422f66 | ||
|
|
d4ebace99f | ||
|
|
95e09c8e2a | ||
|
|
4623d59206 | ||
|
|
e68b1ad428 | ||
|
|
072e2ece15 | ||
|
|
11130037fe | ||
|
|
ba66ef14cc | ||
|
|
8aacc23882 | ||
|
|
03e5fd3dae | ||
|
|
da52598c08 | ||
|
|
52389731eb | ||
|
|
21864f6f95 | ||
|
|
00f8625280 | ||
|
|
c34e29c712 | ||
|
|
e0ae3359f1 | ||
|
|
c2666bacd7 | ||
|
|
4eefd9c3ce | ||
|
|
211456242e | ||
|
|
6f244c4661 | ||
|
|
47bf6d724b | ||
|
|
5c710ad032 | ||
|
|
dda5a05cbb | ||
|
|
e0a63e0290 | ||
|
|
9246659589 | ||
|
|
377cdb84b4 | ||
|
|
0e759f25fd | ||
|
|
b408bb4f6e | ||
|
|
1356479415 | ||
|
|
ec5b4e704f | ||
|
|
aa9e617510 | ||
|
|
ecb739ce96 | ||
|
|
3b72140435 | ||
|
|
27a6770569 | ||
|
|
2ff611167a | ||
|
|
e83e178b63 | ||
|
|
068a757373 | ||
|
|
0ad4527719 | ||
|
|
8c6327d024 | ||
|
|
aecbb2ab02 | ||
|
|
52b11083fe | ||
|
|
a8c87ce54b | ||
|
|
ddb3240591 | ||
|
|
f25ef639f2 | ||
|
|
ab7d3ce4aa | ||
|
|
50db922cef | ||
|
|
a2708edc37 | ||
|
|
603a5005e2 | ||
|
|
b4f68bce76 | ||
|
|
a76cec1539 | ||
|
|
694e6bcae3 | ||
|
|
b85b18cf5f | ||
|
|
04c707f874 | ||
|
|
99142fd662 | ||
|
|
0c5cb17d96 | ||
|
|
cabde313b5 | ||
|
|
8f68bb342d | ||
|
|
fab75d3a32 | ||
|
|
d19bf98dc4 | ||
|
|
b0f41c0360 | ||
|
|
6ebd60feaa | ||
|
|
dd6007b309 | ||
|
|
fde203379d | ||
|
|
fcb3efee01 | ||
|
|
19a21099ed | ||
|
|
20ca7e71c7 | ||
|
|
002202ff5f | ||
|
|
32487137e8 | ||
|
|
f327ab30a6 | ||
|
|
e7545cbc28 | ||
|
|
eba757d2cd | ||
|
|
4119763e23 | ||
|
|
e830a6d6f5 | ||
|
|
704cd97f21 | ||
|
|
47a0dd696f | ||
|
|
c64791e3a8 | ||
|
|
e82d50a3c5 | ||
|
|
0a7aa9e3e2 | ||
|
|
13ca134d12 | ||
|
|
8671e9a391 | ||
|
|
a7de89f45c | ||
|
|
e9f51e3302 | ||
|
|
5491f8c459 | ||
|
|
de71677208 | ||
|
|
653ee2b625 | ||
|
|
62694b1ce7 | ||
|
|
9c0ad2b825 | ||
|
|
88b529593f | ||
|
|
0351698ef7 | ||
|
|
984df75f83 | ||
|
|
402a8fb967 | ||
|
|
45e3027f81 | ||
|
|
1d655a07cd | ||
|
|
c5e768ffe3 | ||
|
|
8cc6f10634 | ||
|
|
aeac83d643 | ||
|
|
95efcf6803 | ||
|
|
44a78cc821 | ||
|
|
e0918a7a89 | ||
|
|
b52310f641 | ||
|
|
e3219ba452 | ||
|
|
7079c17a0f | ||
|
|
3b8450036a | ||
|
|
defdf34e60 | ||
|
|
6827368e60 | ||
|
|
a409167f64 | ||
|
|
a076b9257d | ||
|
|
7e772b4ee9 | ||
|
|
955a86803f | ||
|
|
d5bacaba63 | ||
|
|
3069deb019 | ||
|
|
7f4bf71807 | ||
|
|
f3e00b6d62 | ||
|
|
feef0f484d | ||
|
|
9adbd4031f | ||
|
|
e0d3101066 | ||
|
|
485387ebbe | ||
|
|
9ac628f020 | ||
|
|
07664c4d54 | ||
|
|
d3dbdb4491 | ||
|
|
90ee9ffe36 | ||
|
|
15e6383aad | ||
|
|
2a0d0b4224 | ||
|
|
02fd75c018 | ||
|
|
a87fec0cbd | ||
|
|
11842d396a | ||
|
|
72854cde44 | ||
|
|
b71c8005e7 | ||
|
|
0994afa25b | ||
|
|
7d5693e0fb | ||
|
|
feaed7ea00 | ||
|
|
8340371f9c | ||
|
|
824caaffd0 | ||
|
|
c0b3fa9ff7 | ||
|
|
e809b9328b | ||
|
|
53defd3108 | ||
|
|
a166dc77bc | ||
|
|
68ed208613 | ||
|
|
8f71dac417 | ||
|
|
5f24da7e18 | ||
|
|
4e61f1f23c | ||
|
|
cbfcaeba8b | ||
|
|
9a8abeac28 | ||
|
|
b0f42466f0 | ||
|
|
bcd7d62d0b | ||
|
|
703f5a22fd | ||
|
|
1ee8e339af | ||
|
|
dffde64079 | ||
|
|
17bc184e28 | ||
|
|
0ba9ee0695 | ||
|
|
c40214e20f | ||
|
|
a3aac3d737 | ||
|
|
7bbe62019a | ||
|
|
b898b9d9e6 | ||
|
|
b217372fea | ||
|
|
b2d2c8e596 | ||
|
|
68e37b8f9a | ||
|
|
fa2d7797f4 | ||
|
|
1885dab066 | ||
|
|
9425f5b772 | ||
|
|
83ed3c8b50 | ||
|
|
f4690e296d | ||
|
|
68c350b4c0 | ||
|
|
da0207f5cb | ||
|
|
2455f1158f | ||
|
|
1031fc4923 | ||
|
|
6beaacb905 | ||
|
|
c46ee7c420 | ||
|
|
227f0bce3d | ||
|
|
611e1c2b19 | ||
|
|
5f974b7457 | ||
|
|
3ef35105c8 | ||
|
|
ec768a2e89 | ||
|
|
b580d3c25a | ||
|
|
ce14f190fb | ||
|
|
4e3da005d4 | ||
|
|
0d9967e8d8 | ||
|
|
2624a0a7ea | ||
|
|
8755d5cbc0 | ||
|
|
abb6d7fbdb | ||
|
|
fc04192c99 | ||
|
|
d4110d3b2a | ||
|
|
05c1751d29 | ||
|
|
6ad042b349 | ||
|
|
e52d8b4dbd | ||
|
|
f288e3469c | ||
|
|
5bb87c6da5 | ||
|
|
03768a5f90 | ||
|
|
a84366368f | ||
|
|
29e6a10e42 | ||
|
|
febd280fba | ||
|
|
73964b374c | ||
|
|
bad6a4b211 | ||
|
|
57d3c52df9 | ||
|
|
d309de2557 | ||
|
|
d5d56ede8b | ||
|
|
6613c29652 | ||
|
|
1a6de25ab6 | ||
|
|
b62c1364a9 | ||
|
|
b59162737d | ||
|
|
543dcb27d8 | ||
|
|
22941168cd | ||
|
|
33dc845de8 | ||
|
|
be0f23beb3 | ||
|
|
b76f2163a4 | ||
|
|
04aa471526 | ||
|
|
b756a67c2a | ||
|
|
a76ee010eb | ||
|
|
eb1fef1f92 | ||
|
|
e498cc7d48 | ||
|
|
a26abe079e | ||
|
|
199b6bdabb | ||
|
|
e4bc7bd1cd | ||
|
|
20651df307 | ||
|
|
f857933748 | ||
|
|
efe2b7c539 | ||
|
|
e090153d93 | ||
|
|
5088b02bfe | ||
|
|
57a716b57a | ||
|
|
1b51714f3b | ||
|
|
cb3d35faf9 | ||
|
|
a0c83b4854 | ||
|
|
1b3ee0e94f | ||
|
|
552a6e7f1c | ||
|
|
38bfb1087b | ||
|
|
2dc55873f0 | ||
|
|
4b1898bfaf | ||
|
|
125bf6f270 | ||
|
|
1873c52aa6 | ||
|
|
ec1e113b4c | ||
|
|
347efac0cd | ||
|
|
b7b5bf58aa | ||
|
|
a324c97815 | ||
|
|
f263a0bc91 | ||
|
|
6a9299018c | ||
|
|
ee471a48bd | ||
|
|
879d7c23b7 | ||
|
|
934b09238e | ||
|
|
1fd8e4435e | ||
|
|
50fd42d0c2 | ||
|
|
399958c881 | ||
|
|
78c93d7e39 | ||
|
|
e3b8a60584 | ||
|
|
b7263edfd0 | ||
|
|
1ee749b352 | ||
|
|
f93734f9e3 | ||
|
|
e211dfa1c2 | ||
|
|
0f7deb1d2a | ||
|
|
f2cb16a5be | ||
|
|
98477e27aa | ||
|
|
4149db1a01 | ||
|
|
9ac921380f | ||
|
|
286e24629f | ||
|
|
ab2efc0c5c | ||
|
|
60d6078e1f | ||
|
|
f94492b2d3 | ||
|
|
f03bb61747 | ||
|
|
dc4e8bae98 | ||
|
|
ac26f8be8b | ||
|
|
8c79499573 | ||
|
|
63fbcc5fc8 | ||
|
|
cad217af19 | ||
|
|
a6ad4a8293 | ||
|
|
503999cb32 | ||
|
|
c2d8f2443e | ||
|
|
4571ed7e2f | ||
|
|
ef5cbd3ba3 | ||
|
|
5c162bd7ce | ||
|
|
7bdaaa25c1 | ||
|
|
9a5a02b654 | ||
|
|
4fea6b6e9b | ||
|
|
bd8b8822ac | ||
|
|
0a44c3ec49 | ||
|
|
3262984386 | ||
|
|
180265c8f4 | ||
|
|
a9b4d33cd2 | ||
|
|
5dfb9b28f7 | ||
|
|
ec75793ac3 | ||
|
|
cd4da36863 | ||
|
|
1749e22569 | ||
|
|
0cce88cfbc | ||
|
|
61e83a300b | ||
|
|
136a13aac7 | ||
|
|
2c90db9ae7 | ||
|
|
507e051a5a | ||
|
|
b5bf9ed1d7 | ||
|
|
215eb7e473 | ||
|
|
f42233699a | ||
|
|
1bec68df4d | ||
|
|
d8576e72eb | ||
|
|
7265468e8d | ||
|
|
d07f36dedd | ||
|
|
364a1b71ec | ||
|
|
daee6d210f | ||
|
|
96be0071e6 | ||
|
|
ff8e1dfb47 | ||
|
|
d26db6f213 | ||
|
|
bb6c753583 | ||
|
|
ca08e4b950 | ||
|
|
5a6b02dbd3 | ||
|
|
14416b1050 | ||
|
|
da4e6fc532 | ||
|
|
57d8b69a6d | ||
|
|
c9d8a8661c | ||
|
|
4a3d23e0e6 | ||
|
|
a3666f2ae5 | ||
|
|
c3e000e574 | ||
|
|
dd5481930a | ||
|
|
842328c661 | ||
|
|
8f75384e2e | ||
|
|
193faa00ce | ||
|
|
5e5383b399 | ||
|
|
cb6b29dbe3 | ||
|
|
82b0819051 | ||
|
|
e12ab4afa4 | ||
|
|
1416f631cc | ||
|
|
dbaac47d1e | ||
|
|
cf0ae5e31b | ||
|
|
8891f07362 | ||
|
|
d78974ec59 | ||
|
|
32be26c4d7 |
210
.dockerignore
Normal file
210
.dockerignore
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.run
|
||||||
|
docs
|
||||||
|
test
|
||||||
|
typings
|
||||||
|
*Client.py
|
||||||
|
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
*_Spoiler.txt
|
||||||
|
*.bmbp
|
||||||
|
*.apbp
|
||||||
|
*.apl2ac
|
||||||
|
*.apm3
|
||||||
|
*.apmc
|
||||||
|
*.apz5
|
||||||
|
*.aptloz
|
||||||
|
*.apemerald
|
||||||
|
*.pyc
|
||||||
|
*.pyd
|
||||||
|
*.sfc
|
||||||
|
*.z64
|
||||||
|
*.n64
|
||||||
|
*.nes
|
||||||
|
*.smc
|
||||||
|
*.sms
|
||||||
|
*.gb
|
||||||
|
*.gbc
|
||||||
|
*.gba
|
||||||
|
*.wixobj
|
||||||
|
*.lck
|
||||||
|
*.db3
|
||||||
|
*multidata
|
||||||
|
*multisave
|
||||||
|
*.archipelago
|
||||||
|
*.apsave
|
||||||
|
*.BIN
|
||||||
|
*.puml
|
||||||
|
|
||||||
|
setups
|
||||||
|
build
|
||||||
|
bundle/components.wxs
|
||||||
|
dist
|
||||||
|
/prof/
|
||||||
|
README.html
|
||||||
|
.vs/
|
||||||
|
EnemizerCLI/
|
||||||
|
/Players/
|
||||||
|
/SNI/
|
||||||
|
/sni-*/
|
||||||
|
/appimagetool*
|
||||||
|
/host.yaml
|
||||||
|
/options.yaml
|
||||||
|
/config.yaml
|
||||||
|
/logs/
|
||||||
|
_persistent_storage.yaml
|
||||||
|
mystery_result_*.yaml
|
||||||
|
*-errors.txt
|
||||||
|
success.txt
|
||||||
|
output/
|
||||||
|
Output Logs/
|
||||||
|
/factorio/
|
||||||
|
/Minecraft Forge Server/
|
||||||
|
/WebHostLib/static/generated
|
||||||
|
/freeze_requirements.txt
|
||||||
|
/Archipelago.zip
|
||||||
|
/setup.ini
|
||||||
|
/installdelete.iss
|
||||||
|
/data/user.kv
|
||||||
|
/datapackage
|
||||||
|
/custom_worlds
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
*.dll
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
installer.log
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# vim editor
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv*
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
/venv*/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
*.code-workspace
|
||||||
|
shell.nix
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# Cython intermediates
|
||||||
|
_speedups.c
|
||||||
|
_speedups.cpp
|
||||||
|
_speedups.html
|
||||||
|
|
||||||
|
# minecraft server stuff
|
||||||
|
jdk*/
|
||||||
|
minecraft*/
|
||||||
|
minecraft_versions.json
|
||||||
|
!worlds/minecraft/
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
#undertale stuff
|
||||||
|
/Undertale/
|
||||||
|
|
||||||
|
# OS General Files
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
Thumbs.db
|
||||||
|
[Dd]esktop.ini
|
||||||
1
.github/labeler.yml
vendored
1
.github/labeler.yml
vendored
@@ -21,7 +21,6 @@
|
|||||||
- '!data/**'
|
- '!data/**'
|
||||||
- '!.run/**'
|
- '!.run/**'
|
||||||
- '!.github/**'
|
- '!.github/**'
|
||||||
- '!worlds_disabled/**'
|
|
||||||
- '!worlds/**'
|
- '!worlds/**'
|
||||||
- '!WebHost.py'
|
- '!WebHost.py'
|
||||||
- '!WebHostLib/**'
|
- '!WebHostLib/**'
|
||||||
|
|||||||
2
.github/pyright-config.json
vendored
2
.github/pyright-config.json
vendored
@@ -29,7 +29,7 @@
|
|||||||
"reportMissingImports": true,
|
"reportMissingImports": true,
|
||||||
"reportMissingTypeStubs": true,
|
"reportMissingTypeStubs": true,
|
||||||
|
|
||||||
"pythonVersion": "3.10",
|
"pythonVersion": "3.11",
|
||||||
"pythonPlatform": "Windows",
|
"pythonPlatform": "Windows",
|
||||||
|
|
||||||
"executionEnvironments": [
|
"executionEnvironments": [
|
||||||
|
|||||||
4
.github/workflows/analyze-modified-files.yml
vendored
4
.github/workflows/analyze-modified-files.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
|||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
if: env.diff != ''
|
if: env.diff != ''
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.11'
|
||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
if: env.diff != ''
|
if: env.diff != ''
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
if: env.diff != '' && matrix.task == 'flake8'
|
if: env.diff != '' && matrix.task == 'flake8'
|
||||||
run: |
|
run: |
|
||||||
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
|
flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }}
|
||||||
|
|
||||||
- name: "flake8: Lint modified files"
|
- name: "flake8: Lint modified files"
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
51
.github/workflows/build.yml
vendored
51
.github/workflows/build.yml
vendored
@@ -19,14 +19,24 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
APPIMAGETOOL_VERSION: 13
|
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||||
|
# we check the sha256 and require manual intervention if it was updated.
|
||||||
|
APPIMAGETOOL_VERSION: continuous
|
||||||
|
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
|
||||||
|
APPIMAGE_RUNTIME_VERSION: continuous
|
||||||
|
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
|
||||||
|
|
||||||
|
permissions: # permissions required for attestation
|
||||||
|
id-token: 'write'
|
||||||
|
attestations: 'write'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
|
|
||||||
build-win: # RCs will still be built and signed by hand
|
build-win: # RCs and releases may still be built and signed by hand
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
|
# - copy code below to release.yml -
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install python
|
- name: Install python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
@@ -65,6 +75,18 @@ jobs:
|
|||||||
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||||
$SETUP_NAME=$contents[0].Name
|
$SETUP_NAME=$contents[0].Name
|
||||||
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||||
|
# - copy code above to release.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher.exe
|
||||||
|
build/exe.*/ArchipelagoLauncherDebug.exe
|
||||||
|
build/exe.*/ArchipelagoGenerate.exe
|
||||||
|
build/exe.*/ArchipelagoServer.exe
|
||||||
|
dist/${{ env.ZIP_NAME }}
|
||||||
|
setups/${{ env.SETUP_NAME }}
|
||||||
- name: Check build loads expected worlds
|
- name: Check build loads expected worlds
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -81,7 +103,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd build/exe*
|
cd build/exe*
|
||||||
cp Players/Templates/Clique.yaml Players/
|
cp Players/Templates/VVVVVV.yaml Players/
|
||||||
timeout 30 ./ArchipelagoGenerate
|
timeout 30 ./ArchipelagoGenerate
|
||||||
- name: Store 7z
|
- name: Store 7z
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -99,8 +121,8 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7 # keep for 7 days, should be enough
|
retention-days: 7 # keep for 7 days, should be enough
|
||||||
|
|
||||||
build-ubuntu2004:
|
build-ubuntu2204:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
# - copy code below to release.yml -
|
# - copy code below to release.yml -
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -117,10 +139,13 @@ jobs:
|
|||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/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/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
||||||
|
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -142,6 +167,16 @@ jobs:
|
|||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
# - copy code above to release.yml -
|
# - copy code above to release.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher
|
||||||
|
build/exe.*/ArchipelagoGenerate
|
||||||
|
build/exe.*/ArchipelagoServer
|
||||||
|
dist/${{ env.APPIMAGE_NAME }}*
|
||||||
|
dist/${{ env.TAR_NAME }}
|
||||||
- name: Build Again
|
- name: Build Again
|
||||||
run: |
|
run: |
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
@@ -162,7 +197,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd build/exe*
|
cd build/exe*
|
||||||
cp Players/Templates/Clique.yaml Players/
|
cp Players/Templates/VVVVVV.yaml Players/
|
||||||
timeout 30 ./ArchipelagoGenerate
|
timeout 30 ./ArchipelagoGenerate
|
||||||
- name: Store AppImage
|
- name: Store AppImage
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
2
.github/workflows/label-pull-requests.yml
vendored
2
.github/workflows/label-pull-requests.yml
vendored
@@ -6,6 +6,8 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
env:
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
labeler:
|
labeler:
|
||||||
|
|||||||
103
.github/workflows/release.yml
vendored
103
.github/workflows/release.yml
vendored
@@ -5,11 +5,21 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*.*.*'
|
- 'v?[0-9]+.[0-9]+.[0-9]*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ENEMIZER_VERSION: 7.1
|
ENEMIZER_VERSION: 7.1
|
||||||
APPIMAGETOOL_VERSION: 13
|
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||||
|
# we check the sha256 and require manual intervention if it was updated.
|
||||||
|
APPIMAGETOOL_VERSION: continuous
|
||||||
|
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
|
||||||
|
APPIMAGE_RUNTIME_VERSION: continuous
|
||||||
|
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
|
||||||
|
|
||||||
|
permissions: # permissions required for attestation
|
||||||
|
id-token: 'write'
|
||||||
|
attestations: 'write'
|
||||||
|
contents: 'write' # additionally required for release
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
create-release:
|
||||||
@@ -26,11 +36,79 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# build-release-windows: # this is done by hand because of signing
|
|
||||||
# build-release-macos: # LF volunteer
|
# build-release-macos: # LF volunteer
|
||||||
|
|
||||||
build-release-ubuntu2004:
|
build-release-win:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: windows-latest
|
||||||
|
if: ${{ true }} # change to false to skip if release is built by hand
|
||||||
|
needs: create-release
|
||||||
|
steps:
|
||||||
|
- name: Set env
|
||||||
|
shell: bash
|
||||||
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
|
# - code below copied from build.yml -
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '~3.12.7'
|
||||||
|
check-latest: true
|
||||||
|
- name: Download run-time dependencies
|
||||||
|
run: |
|
||||||
|
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||||
|
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||||
|
choco install innosetup --version=6.2.2 --allow-downgrade
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python setup.py build_exe --yes
|
||||||
|
if ( $? -eq $false ) {
|
||||||
|
Write-Error "setup.py failed!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
|
||||||
|
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||||
|
echo "$NAME -> $ZIP_NAME"
|
||||||
|
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
||||||
|
New-Item -Path dist -ItemType Directory -Force
|
||||||
|
cd build
|
||||||
|
Rename-Item "exe.$NAME" Archipelago
|
||||||
|
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||||
|
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
|
||||||
|
- name: Build Setup
|
||||||
|
run: |
|
||||||
|
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
|
||||||
|
if ( $? -eq $false ) {
|
||||||
|
Write-Error "Building setup failed!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
|
||||||
|
$SETUP_NAME=$contents[0].Name
|
||||||
|
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
|
||||||
|
# - code above copied from build.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher.exe
|
||||||
|
build/exe.*/ArchipelagoLauncherDebug.exe
|
||||||
|
build/exe.*/ArchipelagoGenerate.exe
|
||||||
|
build/exe.*/ArchipelagoServer.exe
|
||||||
|
setups/*
|
||||||
|
- name: Add to Release
|
||||||
|
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||||
|
with:
|
||||||
|
draft: true # see above
|
||||||
|
prerelease: false
|
||||||
|
name: Archipelago ${{ env.RELEASE_VERSION }}
|
||||||
|
files: |
|
||||||
|
setups/*
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
build-release-ubuntu2204:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: create-release
|
||||||
steps:
|
steps:
|
||||||
- name: Set env
|
- name: Set env
|
||||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
@@ -49,10 +127,13 @@ jobs:
|
|||||||
- name: Install build-time dependencies
|
- name: Install build-time dependencies
|
||||||
run: |
|
run: |
|
||||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
wget -nv https://github.com/AppImage/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/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
||||||
|
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
|
||||||
chmod a+rx appimagetool-x86_64.AppImage
|
chmod a+rx appimagetool-x86_64.AppImage
|
||||||
./appimagetool-x86_64.AppImage --appimage-extract
|
./appimagetool-x86_64.AppImage --appimage-extract
|
||||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
|
||||||
chmod a+rx appimagetool
|
chmod a+rx appimagetool
|
||||||
- name: Download run-time dependencies
|
- name: Download run-time dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -74,6 +155,14 @@ jobs:
|
|||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||||
# - code above copied from build.yml -
|
# - code above copied from build.yml -
|
||||||
|
- name: Attest Build
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
build/exe.*/ArchipelagoLauncher
|
||||||
|
build/exe.*/ArchipelagoGenerate
|
||||||
|
build/exe.*/ArchipelagoServer
|
||||||
|
dist/*
|
||||||
- name: Add to Release
|
- name: Add to Release
|
||||||
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
|
||||||
with:
|
with:
|
||||||
|
|||||||
18
.github/workflows/unittests.yml
vendored
18
.github/workflows/unittests.yml
vendored
@@ -8,18 +8,24 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- '**'
|
- '**'
|
||||||
- '!docs/**'
|
- '!docs/**'
|
||||||
|
- '!deploy/**'
|
||||||
- '!setup.py'
|
- '!setup.py'
|
||||||
|
- '!Dockerfile'
|
||||||
- '!*.iss'
|
- '!*.iss'
|
||||||
- '!.gitignore'
|
- '!.gitignore'
|
||||||
|
- '!.dockerignore'
|
||||||
- '!.github/workflows/**'
|
- '!.github/workflows/**'
|
||||||
- '.github/workflows/unittests.yml'
|
- '.github/workflows/unittests.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '**'
|
- '**'
|
||||||
- '!docs/**'
|
- '!docs/**'
|
||||||
|
- '!deploy/**'
|
||||||
- '!setup.py'
|
- '!setup.py'
|
||||||
|
- '!Dockerfile'
|
||||||
- '!*.iss'
|
- '!*.iss'
|
||||||
- '!.gitignore'
|
- '!.gitignore'
|
||||||
|
- '!.dockerignore'
|
||||||
- '!.github/workflows/**'
|
- '!.github/workflows/**'
|
||||||
- '.github/workflows/unittests.yml'
|
- '.github/workflows/unittests.yml'
|
||||||
|
|
||||||
@@ -33,15 +39,15 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest]
|
os: [ubuntu-latest]
|
||||||
python:
|
python:
|
||||||
- {version: '3.10'}
|
- {version: '3.11.2'} # Change to '3.11' around 2026-06-10
|
||||||
- {version: '3.11'}
|
|
||||||
- {version: '3.12'}
|
- {version: '3.12'}
|
||||||
|
- {version: '3.13'}
|
||||||
include:
|
include:
|
||||||
- python: {version: '3.10'} # old compat
|
- python: {version: '3.11'} # old compat
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.12'} # current
|
- python: {version: '3.13'} # current
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- python: {version: '3.12'} # current
|
- python: {version: '3.13'} # current
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -69,7 +75,7 @@ jobs:
|
|||||||
os:
|
os:
|
||||||
- ubuntu-latest
|
- ubuntu-latest
|
||||||
python:
|
python:
|
||||||
- {version: '3.12'} # current
|
- {version: '3.13'} # current
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -10,6 +10,7 @@
|
|||||||
*.apmc
|
*.apmc
|
||||||
*.apz5
|
*.apz5
|
||||||
*.aptloz
|
*.aptloz
|
||||||
|
*.aptww
|
||||||
*.apemerald
|
*.apemerald
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyd
|
*.pyd
|
||||||
@@ -55,7 +56,6 @@ success.txt
|
|||||||
output/
|
output/
|
||||||
Output Logs/
|
Output Logs/
|
||||||
/factorio/
|
/factorio/
|
||||||
/Minecraft Forge Server/
|
|
||||||
/WebHostLib/static/generated
|
/WebHostLib/static/generated
|
||||||
/freeze_requirements.txt
|
/freeze_requirements.txt
|
||||||
/Archipelago.zip
|
/Archipelago.zip
|
||||||
@@ -183,12 +183,6 @@ _speedups.c
|
|||||||
_speedups.cpp
|
_speedups.cpp
|
||||||
_speedups.html
|
_speedups.html
|
||||||
|
|
||||||
# minecraft server stuff
|
|
||||||
jdk*/
|
|
||||||
minecraft*/
|
|
||||||
minecraft_versions.json
|
|
||||||
!worlds/minecraft/
|
|
||||||
|
|
||||||
# pyenv
|
# pyenv
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import sys
|
||||||
from worlds.ahit.Client import launch
|
from worlds.ahit.Client import launch
|
||||||
import Utils
|
import Utils
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
@@ -5,4 +6,4 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("AHITClient", exception_logger="Client")
|
Utils.init_logging("AHITClient", exception_logger="Client")
|
||||||
launch()
|
launch(*sys.argv[1:])
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import List
|
|||||||
|
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
from settings import get_settings
|
||||||
from NetUtils import ClientStatus
|
from NetUtils import ClientStatus
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||||
@@ -80,8 +81,8 @@ class AdventureContext(CommonContext):
|
|||||||
self.local_item_locations = {}
|
self.local_item_locations = {}
|
||||||
self.dragon_speed_info = {}
|
self.dragon_speed_info = {}
|
||||||
|
|
||||||
options = Utils.get_settings()
|
options = get_settings().adventure_options
|
||||||
self.display_msgs = options["adventure_options"]["display_msgs"]
|
self.display_msgs = options.display_msgs
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
async def server_auth(self, password_requested: bool = False):
|
||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
@@ -102,7 +103,7 @@ class AdventureContext(CommonContext):
|
|||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd == 'Connected':
|
if cmd == 'Connected':
|
||||||
self.locations_array = None
|
self.locations_array = None
|
||||||
if Utils.get_settings()["adventure_options"].get("death_link", False):
|
if get_settings().adventure_options.as_dict().get("death_link", False):
|
||||||
self.set_deathlink = True
|
self.set_deathlink = True
|
||||||
async_start(self.get_freeincarnates_used())
|
async_start(self.get_freeincarnates_used())
|
||||||
elif cmd == "RoomInfo":
|
elif cmd == "RoomInfo":
|
||||||
@@ -406,6 +407,7 @@ async def atari_sync_task(ctx: AdventureContext):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
except CancelledError:
|
except CancelledError:
|
||||||
pass
|
pass
|
||||||
@@ -415,8 +417,9 @@ async def atari_sync_task(ctx: AdventureContext):
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
|
options = get_settings().adventure_options
|
||||||
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
|
auto_start = options.rom_start
|
||||||
|
rom_args = options.rom_args
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
|
|||||||
429
BaseClasses.py
429
BaseClasses.py
@@ -5,12 +5,14 @@ import functools
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
|
import warnings
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque, defaultdict
|
||||||
from collections.abc import Collection, MutableSequence
|
from collections.abc import Collection, MutableSequence
|
||||||
from enum import IntEnum, IntFlag
|
from enum import IntEnum, IntFlag
|
||||||
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
|
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
|
||||||
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
|
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload)
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
from typing_extensions import NotRequired, TypedDict
|
from typing_extensions import NotRequired, TypedDict
|
||||||
|
|
||||||
@@ -54,12 +56,21 @@ class HasNameAndPlayer(Protocol):
|
|||||||
player: int
|
player: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class PlandoItemBlock:
|
||||||
|
player: int
|
||||||
|
from_pool: bool
|
||||||
|
force: bool | Literal["silent"]
|
||||||
|
worlds: set[int] = dataclasses.field(default_factory=set)
|
||||||
|
items: list[str] = dataclasses.field(default_factory=list)
|
||||||
|
locations: list[str] = dataclasses.field(default_factory=list)
|
||||||
|
resolved_locations: list[Location] = dataclasses.field(default_factory=list)
|
||||||
|
count: dict[str, int] = dataclasses.field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class MultiWorld():
|
class MultiWorld():
|
||||||
debug_types = False
|
debug_types = False
|
||||||
player_name: Dict[int, str]
|
player_name: Dict[int, str]
|
||||||
plando_texts: List[Dict[str, str]]
|
|
||||||
plando_items: List[List[Dict[str, Any]]]
|
|
||||||
plando_connections: List
|
|
||||||
worlds: Dict[int, "AutoWorld.World"]
|
worlds: Dict[int, "AutoWorld.World"]
|
||||||
groups: Dict[int, Group]
|
groups: Dict[int, Group]
|
||||||
regions: RegionManager
|
regions: RegionManager
|
||||||
@@ -83,6 +94,8 @@ class MultiWorld():
|
|||||||
start_location_hints: Dict[int, Options.StartLocationHints]
|
start_location_hints: Dict[int, Options.StartLocationHints]
|
||||||
item_links: Dict[int, Options.ItemLinks]
|
item_links: Dict[int, Options.ItemLinks]
|
||||||
|
|
||||||
|
plando_item_blocks: Dict[int, List[PlandoItemBlock]]
|
||||||
|
|
||||||
game: Dict[int, str]
|
game: Dict[int, str]
|
||||||
|
|
||||||
random: random.Random
|
random: random.Random
|
||||||
@@ -141,17 +154,11 @@ class MultiWorld():
|
|||||||
self.algorithm = 'balanced'
|
self.algorithm = 'balanced'
|
||||||
self.groups = {}
|
self.groups = {}
|
||||||
self.regions = self.RegionManager(players)
|
self.regions = self.RegionManager(players)
|
||||||
self.shops = []
|
|
||||||
self.itempool = []
|
self.itempool = []
|
||||||
self.seed = None
|
self.seed = None
|
||||||
self.seed_name: str = "Unavailable"
|
self.seed_name: str = "Unavailable"
|
||||||
self.precollected_items = {player: [] for player in self.player_ids}
|
self.precollected_items = {player: [] for player in self.player_ids}
|
||||||
self.required_locations = []
|
self.required_locations = []
|
||||||
self.light_world_light_cone = False
|
|
||||||
self.dark_world_light_cone = False
|
|
||||||
self.rupoor_cost = 10
|
|
||||||
self.aga_randomness = True
|
|
||||||
self.save_and_quit_from_boss = True
|
|
||||||
self.custom = False
|
self.custom = False
|
||||||
self.customitemarray = []
|
self.customitemarray = []
|
||||||
self.shuffle_ganon = True
|
self.shuffle_ganon = True
|
||||||
@@ -160,18 +167,17 @@ class MultiWorld():
|
|||||||
self.local_early_items = {player: {} for player in self.player_ids}
|
self.local_early_items = {player: {} for player in self.player_ids}
|
||||||
self.indirect_connections = {}
|
self.indirect_connections = {}
|
||||||
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
||||||
|
self.plando_item_blocks = {}
|
||||||
|
|
||||||
for player in range(1, players + 1):
|
for player in range(1, players + 1):
|
||||||
def set_player_attr(attr: str, val) -> None:
|
def set_player_attr(attr: str, val) -> None:
|
||||||
self.__dict__.setdefault(attr, {})[player] = val
|
self.__dict__.setdefault(attr, {})[player] = val
|
||||||
set_player_attr('plando_items', [])
|
set_player_attr('plando_item_blocks', [])
|
||||||
set_player_attr('plando_texts', {})
|
|
||||||
set_player_attr('plando_connections', [])
|
|
||||||
set_player_attr('game', "Archipelago")
|
set_player_attr('game', "Archipelago")
|
||||||
set_player_attr('completion_condition', lambda state: True)
|
set_player_attr('completion_condition', lambda state: True)
|
||||||
self.worlds = {}
|
self.worlds = {}
|
||||||
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
||||||
"world's random object instead (usually self.random)")
|
"world's random object instead (usually self.random)", True)
|
||||||
self.plando_options = PlandoOptions.none
|
self.plando_options = PlandoOptions.none
|
||||||
|
|
||||||
def get_all_ids(self) -> Tuple[int, ...]:
|
def get_all_ids(self) -> Tuple[int, ...]:
|
||||||
@@ -216,17 +222,8 @@ class MultiWorld():
|
|||||||
self.seed_name = name if name else str(self.seed)
|
self.seed_name = name if name else str(self.seed)
|
||||||
|
|
||||||
def set_options(self, args: Namespace) -> None:
|
def set_options(self, args: Namespace) -> None:
|
||||||
# TODO - remove this section once all worlds use options dataclasses
|
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
|
|
||||||
all_keys: Set[str] = {key for player in self.player_ids for key in
|
|
||||||
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
|
|
||||||
for option_key in all_keys:
|
|
||||||
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
|
|
||||||
f"Please use `self.options.{option_key}` instead.")
|
|
||||||
option.update(getattr(args, option_key, {}))
|
|
||||||
setattr(self, option_key, option)
|
|
||||||
|
|
||||||
for player in self.player_ids:
|
for player in self.player_ids:
|
||||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||||
self.worlds[player] = world_type(self, player)
|
self.worlds[player] = world_type(self, player)
|
||||||
@@ -427,23 +424,39 @@ class MultiWorld():
|
|||||||
def get_location(self, location_name: str, player: int) -> Location:
|
def get_location(self, location_name: str, player: int) -> Location:
|
||||||
return self.regions.location_cache[player][location_name]
|
return self.regions.location_cache[player][location_name]
|
||||||
|
|
||||||
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState:
|
def get_all_state(self, use_cache: bool | None = None, allow_partial_entrances: bool = False,
|
||||||
cached = getattr(self, "_all_state", None)
|
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
|
||||||
if use_cache and cached:
|
"""
|
||||||
return cached.copy()
|
Creates a new CollectionState, and collects all precollected items, all items in the multiworld itempool, those
|
||||||
|
specified in each worlds' `get_pre_fill_items()`, and then sweeps the multiworld collecting any other items
|
||||||
|
it is able to reach, building as complete of a completed game state as possible.
|
||||||
|
|
||||||
|
: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)
|
ret = CollectionState(self, allow_partial_entrances)
|
||||||
|
|
||||||
for item in self.itempool:
|
for item in self.itempool:
|
||||||
self.worlds[item.player].collect(ret, item)
|
self.worlds[item.player].collect(ret, item)
|
||||||
for player in self.player_ids:
|
if collect_pre_fill_items:
|
||||||
subworld = self.worlds[player]
|
for player in self.player_ids:
|
||||||
for item in subworld.get_pre_fill_items():
|
subworld = self.worlds[player]
|
||||||
subworld.collect(ret, item)
|
for item in subworld.get_pre_fill_items():
|
||||||
ret.sweep_for_advancements()
|
subworld.collect(ret, item)
|
||||||
|
if perform_sweep:
|
||||||
|
ret.sweep_for_advancements()
|
||||||
|
|
||||||
if use_cache:
|
|
||||||
self._all_state = ret
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def get_items(self) -> List[Item]:
|
def get_items(self) -> List[Item]:
|
||||||
@@ -545,7 +558,9 @@ class MultiWorld():
|
|||||||
else:
|
else:
|
||||||
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
|
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
|
||||||
|
|
||||||
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
|
def can_beat_game(self,
|
||||||
|
starting_state: Optional[CollectionState] = None,
|
||||||
|
locations: Optional[Iterable[Location]] = None) -> bool:
|
||||||
if starting_state:
|
if starting_state:
|
||||||
if self.has_beaten_game(starting_state):
|
if self.has_beaten_game(starting_state):
|
||||||
return True
|
return True
|
||||||
@@ -554,25 +569,10 @@ class MultiWorld():
|
|||||||
state = CollectionState(self)
|
state = CollectionState(self)
|
||||||
if self.has_beaten_game(state):
|
if self.has_beaten_game(state):
|
||||||
return True
|
return True
|
||||||
prog_locations = {location for location in self.get_locations() if location.item
|
|
||||||
and location.item.advancement and location not in state.locations_checked}
|
|
||||||
|
|
||||||
while prog_locations:
|
|
||||||
sphere: Set[Location] = set()
|
|
||||||
# build up spheres of collection radius.
|
|
||||||
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
|
||||||
for location in prog_locations:
|
|
||||||
if location.can_reach(state):
|
|
||||||
sphere.add(location)
|
|
||||||
|
|
||||||
if not sphere:
|
|
||||||
# ran out of places and did not finish yet, quit
|
|
||||||
return False
|
|
||||||
|
|
||||||
for location in sphere:
|
|
||||||
state.collect(location.item, True, location)
|
|
||||||
prog_locations -= sphere
|
|
||||||
|
|
||||||
|
for _ in state.sweep_for_advancements(locations,
|
||||||
|
yield_each_sweep=True,
|
||||||
|
checked_locations=state.locations_checked):
|
||||||
if self.has_beaten_game(state):
|
if self.has_beaten_game(state):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -616,7 +616,7 @@ class MultiWorld():
|
|||||||
locations: Set[Location] = set()
|
locations: Set[Location] = set()
|
||||||
events: Set[Location] = set()
|
events: Set[Location] = set()
|
||||||
for location in self.get_filled_locations():
|
for location in self.get_filled_locations():
|
||||||
if type(location.item.code) is int:
|
if type(location.item.code) is int and type(location.address) is int:
|
||||||
locations.add(location)
|
locations.add(location)
|
||||||
else:
|
else:
|
||||||
events.add(location)
|
events.add(location)
|
||||||
@@ -688,6 +688,12 @@ class MultiWorld():
|
|||||||
sphere.append(locations.pop(n))
|
sphere.append(locations.pop(n))
|
||||||
|
|
||||||
if not sphere:
|
if not sphere:
|
||||||
|
if __debug__:
|
||||||
|
from Fill import FillError
|
||||||
|
raise FillError(
|
||||||
|
f"Could not access required locations for accessibility check. Missing: {locations}",
|
||||||
|
multiworld=self,
|
||||||
|
)
|
||||||
# ran out of places and did not finish yet, quit
|
# ran out of places and did not finish yet, quit
|
||||||
logging.warning(f"Could not access required locations for accessibility check."
|
logging.warning(f"Could not access required locations for accessibility check."
|
||||||
f" Missing: {locations}")
|
f" Missing: {locations}")
|
||||||
@@ -723,6 +729,7 @@ class CollectionState():
|
|||||||
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
||||||
|
|
||||||
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
|
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
|
||||||
|
assert parent.worlds, "CollectionState created without worlds initialized in parent"
|
||||||
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
|
||||||
self.multiworld = parent
|
self.multiworld = parent
|
||||||
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
|
||||||
@@ -850,20 +857,133 @@ class CollectionState():
|
|||||||
"Please switch over to sweep_for_advancements.")
|
"Please switch over to sweep_for_advancements.")
|
||||||
return self.sweep_for_advancements(locations)
|
return self.sweep_for_advancements(locations)
|
||||||
|
|
||||||
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None) -> None:
|
def _sweep_for_advancements_impl(self, advancements_per_player: List[Tuple[int, List[Location]]],
|
||||||
if locations is None:
|
yield_each_sweep: bool) -> Iterator[None]:
|
||||||
locations = self.multiworld.get_filled_locations()
|
"""
|
||||||
reachable_advancements = True
|
The implementation for sweep_for_advancements is separated here because it returns a generator due to the use
|
||||||
# since the loop has a good chance to run more than once, only filter the advancements once
|
of a yield statement.
|
||||||
locations = {location for location in locations if location.advancement and location not in self.advancements}
|
"""
|
||||||
|
all_players = {player for player, _ in advancements_per_player}
|
||||||
|
players_to_check = all_players
|
||||||
|
# As an optimization, it is assumed that each player's world only logically depends on itself. However, worlds
|
||||||
|
# are allowed to logically depend on other worlds, so once there are no more players that should be checked
|
||||||
|
# under this assumption, an extra sweep iteration is performed that checks every player, to confirm that the
|
||||||
|
# sweep is finished.
|
||||||
|
checking_if_finished = False
|
||||||
|
while players_to_check:
|
||||||
|
next_advancements_per_player: List[Tuple[int, List[Location]]] = []
|
||||||
|
next_players_to_check = set()
|
||||||
|
|
||||||
while reachable_advancements:
|
for player, locations in advancements_per_player:
|
||||||
reachable_advancements = {location for location in locations if location.can_reach(self)}
|
if player not in players_to_check:
|
||||||
locations -= reachable_advancements
|
next_advancements_per_player.append((player, locations))
|
||||||
for advancement in reachable_advancements:
|
continue
|
||||||
self.advancements.add(advancement)
|
|
||||||
assert isinstance(advancement.item, Item), "tried to collect Event with no Item"
|
# Accessibility of each location is checked first because a player's region accessibility cache becomes
|
||||||
self.collect(advancement.item, True, advancement)
|
# stale whenever one of their own items is collected into the state.
|
||||||
|
reachable_locations: List[Location] = []
|
||||||
|
unreachable_locations: List[Location] = []
|
||||||
|
for location in locations:
|
||||||
|
if location.can_reach(self):
|
||||||
|
# Locations containing items that do not belong to `player` could be collected immediately
|
||||||
|
# because they won't stale `player`'s region accessibility cache, but, for simplicity, all the
|
||||||
|
# items at reachable locations are collected in a single loop.
|
||||||
|
reachable_locations.append(location)
|
||||||
|
else:
|
||||||
|
unreachable_locations.append(location)
|
||||||
|
if unreachable_locations:
|
||||||
|
next_advancements_per_player.append((player, unreachable_locations))
|
||||||
|
|
||||||
|
# A previous player's locations processed in the current `while players_to_check` iteration could have
|
||||||
|
# collected items belonging to `player`, but now that all of `player`'s reachable locations have been
|
||||||
|
# found, it can be assumed that `player` will not gain any more reachable locations until another one of
|
||||||
|
# their items is collected.
|
||||||
|
# It would be clearer to not add players to `next_players_to_check` in the first place if they have yet
|
||||||
|
# to be processed in the current `while players_to_check` iteration, but checking if a player should be
|
||||||
|
# added to `next_players_to_check` would need to be run once for every item that is collected, so it is
|
||||||
|
# more performant to instead discard `player` from `next_players_to_check` once their locations have
|
||||||
|
# been processed.
|
||||||
|
next_players_to_check.discard(player)
|
||||||
|
|
||||||
|
# Collect the items from the reachable locations.
|
||||||
|
for advancement in reachable_locations:
|
||||||
|
self.advancements.add(advancement)
|
||||||
|
item = advancement.item
|
||||||
|
assert isinstance(item, Item), "tried to collect advancement Location with no Item"
|
||||||
|
if self.collect(item, True, advancement):
|
||||||
|
# The player the item belongs to may be able to reach additional locations in the next sweep
|
||||||
|
# iteration.
|
||||||
|
next_players_to_check.add(item.player)
|
||||||
|
|
||||||
|
if not next_players_to_check:
|
||||||
|
if not checking_if_finished:
|
||||||
|
# It is assumed that each player's world only logically depends on itself, which may not be the
|
||||||
|
# case, so confirm that the sweep is finished by doing an extra iteration that checks every player.
|
||||||
|
checking_if_finished = True
|
||||||
|
next_players_to_check = all_players
|
||||||
|
else:
|
||||||
|
checking_if_finished = False
|
||||||
|
|
||||||
|
players_to_check = next_players_to_check
|
||||||
|
advancements_per_player = next_advancements_per_player
|
||||||
|
|
||||||
|
if yield_each_sweep:
|
||||||
|
yield
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, *,
|
||||||
|
yield_each_sweep: Literal[True],
|
||||||
|
checked_locations: Optional[Set[Location]] = None) -> Iterator[None]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None,
|
||||||
|
yield_each_sweep: Literal[False] = False,
|
||||||
|
checked_locations: Optional[Set[Location]] = None) -> None: ...
|
||||||
|
|
||||||
|
def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, yield_each_sweep: bool = False,
|
||||||
|
checked_locations: Optional[Set[Location]] = None) -> Optional[Iterator[None]]:
|
||||||
|
"""
|
||||||
|
Sweep through the locations that contain uncollected advancement items, collecting the items into the state
|
||||||
|
until there are no more reachable locations that contain uncollected advancement items.
|
||||||
|
|
||||||
|
:param locations: The locations to sweep through, defaulting to all locations in the multiworld.
|
||||||
|
:param yield_each_sweep: When True, return a generator that yields at the end of each sweep iteration.
|
||||||
|
:param checked_locations: Optional override of locations to filter out from the locations argument, defaults to
|
||||||
|
self.advancements when None.
|
||||||
|
"""
|
||||||
|
if checked_locations is None:
|
||||||
|
checked_locations = self.advancements
|
||||||
|
|
||||||
|
# Since the sweep loop usually performs many iterations, the locations are filtered in advance.
|
||||||
|
# A list of tuples is used, instead of a dictionary, because it is faster to iterate.
|
||||||
|
advancements_per_player: List[Tuple[int, List[Location]]]
|
||||||
|
if locations is None:
|
||||||
|
# `location.advancement` can only be True for filled locations, so unfilled locations are filtered out.
|
||||||
|
advancements_per_player = []
|
||||||
|
for player, locations_dict in self.multiworld.regions.location_cache.items():
|
||||||
|
filtered_locations = [location for location in locations_dict.values()
|
||||||
|
if location.advancement and location not in checked_locations]
|
||||||
|
if filtered_locations:
|
||||||
|
advancements_per_player.append((player, filtered_locations))
|
||||||
|
else:
|
||||||
|
# Filter and separate the locations into a list for each player.
|
||||||
|
advancements_per_player_dict: Dict[int, List[Location]] = defaultdict(list)
|
||||||
|
for location in locations:
|
||||||
|
if location.advancement and location not in checked_locations:
|
||||||
|
advancements_per_player_dict[location.player].append(location)
|
||||||
|
# Convert to a list of tuples.
|
||||||
|
advancements_per_player = list(advancements_per_player_dict.items())
|
||||||
|
del advancements_per_player_dict
|
||||||
|
|
||||||
|
if yield_each_sweep:
|
||||||
|
# Return a generator that will yield at the end of each sweep iteration.
|
||||||
|
return self._sweep_for_advancements_impl(advancements_per_player, True)
|
||||||
|
else:
|
||||||
|
# Create the generator, but tell it not to yield anything, so it will run to completion in zero iterations
|
||||||
|
# once started, then start and exhaust the generator by attempting to iterate it.
|
||||||
|
for _ in self._sweep_for_advancements_impl(advancements_per_player, False):
|
||||||
|
assert False, "Generator yielded when it should have run to completion without yielding"
|
||||||
|
return None
|
||||||
|
|
||||||
# item name related
|
# item name related
|
||||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||||
@@ -999,6 +1119,17 @@ class CollectionState():
|
|||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
def add_item(self, item: str, player: int, count: int = 1) -> None:
|
||||||
|
"""
|
||||||
|
Adds the item to state.
|
||||||
|
|
||||||
|
:param item: The item to be added.
|
||||||
|
:param player: The player the item is for.
|
||||||
|
:param count: How many of the item to add.
|
||||||
|
"""
|
||||||
|
assert count > 0
|
||||||
|
self.prog_items[player][item] += count
|
||||||
|
|
||||||
def remove(self, item: Item):
|
def remove(self, item: Item):
|
||||||
changed = self.multiworld.worlds[item.player].remove(self, item)
|
changed = self.multiworld.worlds[item.player].remove(self, item)
|
||||||
if changed:
|
if changed:
|
||||||
@@ -1007,6 +1138,33 @@ class CollectionState():
|
|||||||
self.blocked_connections[item.player] = set()
|
self.blocked_connections[item.player] = set()
|
||||||
self.stale[item.player] = True
|
self.stale[item.player] = True
|
||||||
|
|
||||||
|
def remove_item(self, item: str, player: int, count: int = 1) -> None:
|
||||||
|
"""
|
||||||
|
Removes the item from state.
|
||||||
|
|
||||||
|
:param item: The item to be removed.
|
||||||
|
:param player: The player the item is for.
|
||||||
|
:param count: How many of the item to remove.
|
||||||
|
"""
|
||||||
|
assert count > 0
|
||||||
|
self.prog_items[player][item] -= count
|
||||||
|
if self.prog_items[player][item] < 1:
|
||||||
|
del (self.prog_items[player][item])
|
||||||
|
|
||||||
|
def set_item(self, item: str, player: int, count: int) -> None:
|
||||||
|
"""
|
||||||
|
Sets the item in state equal to the provided count.
|
||||||
|
|
||||||
|
:param item: The item to modify.
|
||||||
|
:param player: The player the item is for.
|
||||||
|
:param count: How many of the item to now have.
|
||||||
|
"""
|
||||||
|
assert count >= 0
|
||||||
|
if count == 0:
|
||||||
|
del (self.prog_items[player][item])
|
||||||
|
else:
|
||||||
|
self.prog_items[player][item] = count
|
||||||
|
|
||||||
|
|
||||||
class EntranceType(IntEnum):
|
class EntranceType(IntEnum):
|
||||||
ONE_WAY = 1
|
ONE_WAY = 1
|
||||||
@@ -1022,9 +1180,6 @@ class Entrance:
|
|||||||
connected_region: Optional[Region] = None
|
connected_region: Optional[Region] = None
|
||||||
randomization_group: int
|
randomization_group: int
|
||||||
randomization_type: EntranceType
|
randomization_type: EntranceType
|
||||||
# LttP specific, TODO: should make a LttPEntrance
|
|
||||||
addresses = None
|
|
||||||
target = None
|
|
||||||
|
|
||||||
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
|
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
|
||||||
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
|
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
|
||||||
@@ -1043,10 +1198,8 @@ class Entrance:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
|
def connect(self, region: Region) -> None:
|
||||||
self.connected_region = region
|
self.connected_region = region
|
||||||
self.target = target
|
|
||||||
self.addresses = addresses
|
|
||||||
region.entrances.append(self)
|
region.entrances.append(self)
|
||||||
|
|
||||||
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
|
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
|
||||||
@@ -1098,13 +1251,16 @@ class Region:
|
|||||||
self.region_manager = region_manager
|
self.region_manager = region_manager
|
||||||
|
|
||||||
def __getitem__(self, index: int) -> Location:
|
def __getitem__(self, index: int) -> Location:
|
||||||
return self._list.__getitem__(index)
|
return self._list[index]
|
||||||
|
|
||||||
def __setitem__(self, index: int, value: Location) -> None:
|
def __setitem__(self, index: int, value: Location) -> None:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self._list.__len__()
|
return len(self._list)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self._list)
|
||||||
|
|
||||||
# This seems to not be needed, but that's a bit suspicious.
|
# This seems to not be needed, but that's a bit suspicious.
|
||||||
# def __del__(self):
|
# def __del__(self):
|
||||||
@@ -1115,8 +1271,8 @@ class Region:
|
|||||||
|
|
||||||
class LocationRegister(Register):
|
class LocationRegister(Register):
|
||||||
def __delitem__(self, index: int) -> None:
|
def __delitem__(self, index: int) -> None:
|
||||||
location: Location = self._list.__getitem__(index)
|
location: Location = self._list[index]
|
||||||
self._list.__delitem__(index)
|
del self._list[index]
|
||||||
del(self.region_manager.location_cache[location.player][location.name])
|
del(self.region_manager.location_cache[location.player][location.name])
|
||||||
|
|
||||||
def insert(self, index: int, value: Location) -> None:
|
def insert(self, index: int, value: Location) -> None:
|
||||||
@@ -1127,8 +1283,8 @@ class Region:
|
|||||||
|
|
||||||
class EntranceRegister(Register):
|
class EntranceRegister(Register):
|
||||||
def __delitem__(self, index: int) -> None:
|
def __delitem__(self, index: int) -> None:
|
||||||
entrance: Entrance = self._list.__getitem__(index)
|
entrance: Entrance = self._list[index]
|
||||||
self._list.__delitem__(index)
|
del self._list[index]
|
||||||
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
|
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
|
||||||
|
|
||||||
def insert(self, index: int, value: Entrance) -> None:
|
def insert(self, index: int, value: Entrance) -> None:
|
||||||
@@ -1200,6 +1356,48 @@ class Region:
|
|||||||
for location, address in locations.items():
|
for location, address in locations.items():
|
||||||
self.locations.append(location_type(self.player, location, address, self))
|
self.locations.append(location_type(self.player, location, address, self))
|
||||||
|
|
||||||
|
def add_event(
|
||||||
|
self,
|
||||||
|
location_name: str,
|
||||||
|
item_name: str | None = None,
|
||||||
|
rule: Callable[[CollectionState], bool] | None = None,
|
||||||
|
location_type: type[Location] | None = None,
|
||||||
|
item_type: type[Item] | None = None,
|
||||||
|
show_in_spoiler: bool = True,
|
||||||
|
) -> Item:
|
||||||
|
"""
|
||||||
|
Adds an event location/item pair to the region.
|
||||||
|
|
||||||
|
:param location_name: Name for the event location.
|
||||||
|
:param item_name: Name for the event item. If not provided, defaults to location_name.
|
||||||
|
:param rule: Callable to determine access for this event location within its region.
|
||||||
|
:param location_type: Location class to create the event location with. Defaults to BaseClasses.Location.
|
||||||
|
:param item_type: Item class to create the event item with. Defaults to BaseClasses.Item.
|
||||||
|
:param show_in_spoiler: Will be passed along to the created event Location's show_in_spoiler attribute.
|
||||||
|
:return: The created Event Item
|
||||||
|
"""
|
||||||
|
if location_type is None:
|
||||||
|
location_type = Location
|
||||||
|
|
||||||
|
if item_name is None:
|
||||||
|
item_name = location_name
|
||||||
|
|
||||||
|
if item_type is None:
|
||||||
|
item_type = Item
|
||||||
|
|
||||||
|
event_location = location_type(self.player, location_name, None, self)
|
||||||
|
event_location.show_in_spoiler = show_in_spoiler
|
||||||
|
if rule is not None:
|
||||||
|
event_location.access_rule = rule
|
||||||
|
|
||||||
|
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
|
||||||
|
|
||||||
|
event_location.place_locked_item(event_item)
|
||||||
|
|
||||||
|
self.locations.append(event_location)
|
||||||
|
|
||||||
|
return event_item
|
||||||
|
|
||||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
|
||||||
"""
|
"""
|
||||||
@@ -1240,8 +1438,8 @@ class Region:
|
|||||||
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
||||||
|
|
||||||
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
|
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
|
||||||
created entrances will be named "self.name -> connecting_region"
|
created entrances will be named "self.name -> connecting_region"
|
||||||
:param rules: rules for the exits from this region. format is {"connecting_region", rule}
|
:param rules: rules for the exits from this region. format is {"connecting_region": rule}
|
||||||
"""
|
"""
|
||||||
if not isinstance(exits, Dict):
|
if not isinstance(exits, Dict):
|
||||||
exits = dict.fromkeys(exits)
|
exits = dict.fromkeys(exits)
|
||||||
@@ -1310,9 +1508,6 @@ class Location:
|
|||||||
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
|
||||||
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
|
||||||
|
|
||||||
def __hash__(self):
|
|
||||||
return hash((self.name, self.player))
|
|
||||||
|
|
||||||
def __lt__(self, other: Location):
|
def __lt__(self, other: Location):
|
||||||
return (self.player, self.name) < (other.player, other.name)
|
return (self.player, self.name) < (other.player, other.name)
|
||||||
|
|
||||||
@@ -1336,31 +1531,47 @@ class Location:
|
|||||||
|
|
||||||
|
|
||||||
class ItemClassification(IntFlag):
|
class ItemClassification(IntFlag):
|
||||||
filler = 0b0000
|
filler = 0b00000
|
||||||
""" aka trash, as in filler items like ammo, currency etc """
|
""" aka trash, as in filler items like ammo, currency etc """
|
||||||
|
|
||||||
progression = 0b0001
|
progression = 0b00001
|
||||||
""" Item that is logically relevant.
|
""" Item that is logically relevant.
|
||||||
Protects this item from being placed on excluded or unreachable locations. """
|
Protects this item from being placed on excluded or unreachable locations. """
|
||||||
|
|
||||||
useful = 0b0010
|
useful = 0b00010
|
||||||
""" Item that is especially useful.
|
""" Item that is especially useful.
|
||||||
Protects this item from being placed on excluded or unreachable locations.
|
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". """
|
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. """
|
""" Item that is detrimental in some way. """
|
||||||
|
|
||||||
skip_balancing = 0b1000
|
skip_balancing = 0b01000
|
||||||
""" should technically never occur on its own
|
""" should technically never occur on its own
|
||||||
Item that is logically relevant, but progression balancing should not touch.
|
Item that is logically relevant, but progression balancing should not touch.
|
||||||
Typically currency or other counted items. """
|
|
||||||
|
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) """
|
||||||
|
|
||||||
progression_skip_balancing = 0b1001 # only progression gets balanced
|
deprioritized = 0b10000
|
||||||
|
""" Should technically never occur on its own.
|
||||||
|
Will not be considered for priority locations,
|
||||||
|
unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
|
||||||
|
|
||||||
|
Should be used for items that would feel bad for the player to find on a priority location.
|
||||||
|
Usually, these are items that are plentiful or insignificant. """
|
||||||
|
|
||||||
|
progression_deprioritized_skip_balancing = 0b11001
|
||||||
|
""" Since a common case of both skip_balancing and deprioritized is "insignificant progression",
|
||||||
|
these items often want both flags. """
|
||||||
|
|
||||||
|
progression_skip_balancing = 0b01001 # only progression gets balanced
|
||||||
|
progression_deprioritized = 0b10001 # only progression can be placed during priority fill
|
||||||
|
|
||||||
def as_flag(self) -> int:
|
def as_flag(self) -> int:
|
||||||
"""As Network API flag int."""
|
"""As Network API flag int."""
|
||||||
return int(self & 0b0111)
|
return int(self & 0b00111)
|
||||||
|
|
||||||
|
|
||||||
class Item:
|
class Item:
|
||||||
@@ -1404,6 +1615,10 @@ class Item:
|
|||||||
def trap(self) -> bool:
|
def trap(self) -> bool:
|
||||||
return ItemClassification.trap in self.classification
|
return ItemClassification.trap in self.classification
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deprioritized(self) -> bool:
|
||||||
|
return ItemClassification.deprioritized in self.classification
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filler(self) -> bool:
|
def filler(self) -> bool:
|
||||||
return not (self.advancement or self.useful or self.trap)
|
return not (self.advancement or self.useful or self.trap)
|
||||||
@@ -1416,6 +1631,10 @@ class Item:
|
|||||||
def flags(self) -> int:
|
def flags(self) -> int:
|
||||||
return self.classification.as_flag()
|
return self.classification.as_flag()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_event(self) -> bool:
|
||||||
|
return self.code is None
|
||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
def __eq__(self, other: object) -> bool:
|
||||||
if not isinstance(other, Item):
|
if not isinstance(other, Item):
|
||||||
return NotImplemented
|
return NotImplemented
|
||||||
@@ -1509,21 +1728,19 @@ class Spoiler:
|
|||||||
|
|
||||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||||
# reducing each range of influence to the bare minimum required inside it
|
# reducing each range of influence to the bare minimum required inside it
|
||||||
restore_later: Dict[Location, Item] = {}
|
required_locations = {location for sphere in collection_spheres for location in sphere}
|
||||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||||
to_delete: Set[Location] = set()
|
to_delete: Set[Location] = set()
|
||||||
for location in sphere:
|
for location in sphere:
|
||||||
# we remove the item at location and check if game is still beatable
|
# we remove the location from required_locations to sweep from, and check if the game is still beatable
|
||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||||
location.item.player)
|
location.item.player)
|
||||||
old_item = location.item
|
required_locations.remove(location)
|
||||||
location.item = None
|
if multiworld.can_beat_game(state_cache[num], required_locations):
|
||||||
if multiworld.can_beat_game(state_cache[num]):
|
|
||||||
to_delete.add(location)
|
to_delete.add(location)
|
||||||
restore_later[location] = old_item
|
|
||||||
else:
|
else:
|
||||||
# still required, got to keep it around
|
# still required, got to keep it around
|
||||||
location.item = old_item
|
required_locations.add(location)
|
||||||
|
|
||||||
# cull entries in spheres for spoiler walkthrough at end
|
# cull entries in spheres for spoiler walkthrough at end
|
||||||
sphere -= to_delete
|
sphere -= to_delete
|
||||||
@@ -1540,7 +1757,7 @@ class Spoiler:
|
|||||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||||
precollected_items.remove(item)
|
precollected_items.remove(item)
|
||||||
multiworld.state.remove(item)
|
multiworld.state.remove(item)
|
||||||
if not multiworld.can_beat_game():
|
if not multiworld.can_beat_game(multiworld.state, required_locations):
|
||||||
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
|
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
|
||||||
multiworld.push_precollected(item)
|
multiworld.push_precollected(item)
|
||||||
else:
|
else:
|
||||||
@@ -1582,9 +1799,6 @@ class Spoiler:
|
|||||||
self.create_paths(state, collection_spheres)
|
self.create_paths(state, collection_spheres)
|
||||||
|
|
||||||
# repair the multiworld again
|
# repair the multiworld again
|
||||||
for location, item in restore_later.items():
|
|
||||||
location.item = item
|
|
||||||
|
|
||||||
for item in removed_precollected:
|
for item in removed_precollected:
|
||||||
multiworld.push_precollected(item)
|
multiworld.push_precollected(item)
|
||||||
|
|
||||||
@@ -1685,7 +1899,8 @@ class Spoiler:
|
|||||||
if self.unreachables:
|
if self.unreachables:
|
||||||
outfile.write('\n\nUnreachable Progression Items:\n\n')
|
outfile.write('\n\nUnreachable Progression Items:\n\n')
|
||||||
outfile.write(
|
outfile.write(
|
||||||
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
|
'\n'.join(['%s: %s' % (unreachable.item, unreachable)
|
||||||
|
for unreachable in sorted(self.unreachables)]))
|
||||||
|
|
||||||
if self.paths:
|
if self.paths:
|
||||||
outfile.write('\n\nPaths:\n\n')
|
outfile.write('\n\nPaths:\n\n')
|
||||||
@@ -1712,7 +1927,7 @@ class Tutorial(NamedTuple):
|
|||||||
description: str
|
description: str
|
||||||
language: str
|
language: str
|
||||||
file_name: str
|
file_name: str
|
||||||
link: str
|
link: str # unused
|
||||||
authors: List[str]
|
authors: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
271
CommonClient.py
271
CommonClient.py
@@ -21,7 +21,7 @@ import Utils
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Utils.init_logging("TextClient", exception_logger="Client")
|
Utils.init_logging("TextClient", exception_logger="Client")
|
||||||
|
|
||||||
from MultiServer import CommandProcessor
|
from MultiServer import CommandProcessor, mark_raw
|
||||||
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
|
||||||
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
|
||||||
from Utils import Version, stream_input, async_start
|
from Utils import Version, stream_input, async_start
|
||||||
@@ -107,7 +107,9 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
return False
|
return False
|
||||||
count = 0
|
count = 0
|
||||||
checked_count = 0
|
checked_count = 0
|
||||||
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
|
||||||
|
lookup = self.ctx.location_names[self.ctx.game]
|
||||||
|
for location_id, location in lookup.items():
|
||||||
if filter_text and filter_text not in location:
|
if filter_text and filter_text not in location:
|
||||||
continue
|
continue
|
||||||
if location_id < 0:
|
if location_id < 0:
|
||||||
@@ -128,43 +130,87 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
self.output("No missing location checks found.")
|
self.output("No missing location checks found.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cmd_items(self):
|
def output_datapackage_part(self, name: typing.Literal["Item Names", "Location Names"]) -> bool:
|
||||||
|
"""
|
||||||
|
Helper to digest a specific section of this game's datapackage.
|
||||||
|
|
||||||
|
:param name: Printed to the user as context for the part.
|
||||||
|
|
||||||
|
:return: Whether the process was successful.
|
||||||
|
"""
|
||||||
|
if not self.ctx.game:
|
||||||
|
self.output(f"No game set, cannot determine {name}.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names
|
||||||
|
lookup = lookup[self.ctx.game]
|
||||||
|
self.output(f"{name} for {self.ctx.game}")
|
||||||
|
for name in lookup.values():
|
||||||
|
self.output(name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _cmd_items(self) -> bool:
|
||||||
"""List all item names for the currently running game."""
|
"""List all item names for the currently running game."""
|
||||||
if not self.ctx.game:
|
return self.output_datapackage_part("Item Names")
|
||||||
self.output("No game set, cannot determine existing items.")
|
|
||||||
return False
|
|
||||||
self.output(f"Item Names for {self.ctx.game}")
|
|
||||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
|
||||||
self.output(item_name)
|
|
||||||
|
|
||||||
def _cmd_item_groups(self):
|
def _cmd_locations(self) -> bool:
|
||||||
"""List all item group names for the currently running game."""
|
|
||||||
if not self.ctx.game:
|
|
||||||
self.output("No game set, cannot determine existing item groups.")
|
|
||||||
return False
|
|
||||||
self.output(f"Item Group Names for {self.ctx.game}")
|
|
||||||
for group_name in AutoWorldRegister.world_types[self.ctx.game].item_name_groups:
|
|
||||||
self.output(group_name)
|
|
||||||
|
|
||||||
def _cmd_locations(self):
|
|
||||||
"""List all location names for the currently running game."""
|
"""List all location names for the currently running game."""
|
||||||
if not self.ctx.game:
|
return self.output_datapackage_part("Location Names")
|
||||||
self.output("No game set, cannot determine existing locations.")
|
|
||||||
return False
|
|
||||||
self.output(f"Location Names for {self.ctx.game}")
|
|
||||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
|
||||||
self.output(location_name)
|
|
||||||
|
|
||||||
def _cmd_location_groups(self):
|
def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
|
||||||
"""List all location group names for the currently running game."""
|
filter_key: str,
|
||||||
if not self.ctx.game:
|
name: str) -> bool:
|
||||||
self.output("No game set, cannot determine existing location groups.")
|
"""
|
||||||
return False
|
Logs an item or location group from the player's game's datapackage.
|
||||||
self.output(f"Location Group Names for {self.ctx.game}")
|
|
||||||
for group_name in AutoWorldRegister.world_types[self.ctx.game].location_name_groups:
|
|
||||||
self.output(group_name)
|
|
||||||
|
|
||||||
def _cmd_ready(self):
|
:param group_key: Either Item or Location group to be processed.
|
||||||
|
:param filter_key: Which group key to filter to. If an empty string is passed will log all item/location groups.
|
||||||
|
:param name: Printed to the user as context for the part.
|
||||||
|
|
||||||
|
:return: Whether the process was successful.
|
||||||
|
"""
|
||||||
|
if not self.ctx.game:
|
||||||
|
self.output(f"No game set, cannot determine existing {name} Groups.")
|
||||||
|
return False
|
||||||
|
lookup = Utils.persistent_load().get("groups_by_checksum", {}).get(self.ctx.checksums[self.ctx.game], {})\
|
||||||
|
.get(self.ctx.game, {}).get(group_key, {})
|
||||||
|
if lookup is None:
|
||||||
|
self.output("datapackage not yet loaded, try again")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if filter_key:
|
||||||
|
if filter_key not in lookup:
|
||||||
|
self.output(f"Unknown {name} Group {filter_key}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.output(f"{name}s for {name} Group \"{filter_key}\"")
|
||||||
|
for entry in lookup[filter_key]:
|
||||||
|
self.output(entry)
|
||||||
|
else:
|
||||||
|
self.output(f"{name} Groups for {self.ctx.game}")
|
||||||
|
for group in lookup:
|
||||||
|
self.output(group)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@mark_raw
|
||||||
|
def _cmd_item_groups(self, key: str = "") -> bool:
|
||||||
|
"""
|
||||||
|
List all item group names for the currently running game.
|
||||||
|
|
||||||
|
:param key: Which item group to filter to. Will log all groups if empty.
|
||||||
|
"""
|
||||||
|
return self.output_group_part("item_name_groups", key, "Item")
|
||||||
|
|
||||||
|
@mark_raw
|
||||||
|
def _cmd_location_groups(self, key: str = "") -> bool:
|
||||||
|
"""
|
||||||
|
List all location group names for the currently running game.
|
||||||
|
|
||||||
|
:param key: Which item group to filter to. Will log all groups if empty.
|
||||||
|
"""
|
||||||
|
return self.output_group_part("location_name_groups", key, "Location")
|
||||||
|
|
||||||
|
def _cmd_ready(self) -> bool:
|
||||||
"""Send ready status to server."""
|
"""Send ready status to server."""
|
||||||
self.ctx.ready = not self.ctx.ready
|
self.ctx.ready = not self.ctx.ready
|
||||||
if self.ctx.ready:
|
if self.ctx.ready:
|
||||||
@@ -174,6 +220,7 @@ class ClientCommandProcessor(CommandProcessor):
|
|||||||
state = ClientStatus.CLIENT_CONNECTED
|
state = ClientStatus.CLIENT_CONNECTED
|
||||||
self.output("Unreadied.")
|
self.output("Unreadied.")
|
||||||
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||||
|
return True
|
||||||
|
|
||||||
def default(self, raw: str):
|
def default(self, raw: str):
|
||||||
"""The default message parser to be used when parsing any messages that do not match a command"""
|
"""The default message parser to be used when parsing any messages that do not match a command"""
|
||||||
@@ -196,25 +243,12 @@ class CommonContext:
|
|||||||
self.lookup_type: typing.Literal["item", "location"] = lookup_type
|
self.lookup_type: typing.Literal["item", "location"] = lookup_type
|
||||||
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
|
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
|
||||||
self._archipelago_lookup: typing.Dict[int, str] = {}
|
self._archipelago_lookup: typing.Dict[int, str] = {}
|
||||||
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
|
|
||||||
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
|
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
|
||||||
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
|
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
|
||||||
self.warned: bool = False
|
|
||||||
|
|
||||||
# noinspection PyTypeChecker
|
# noinspection PyTypeChecker
|
||||||
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
|
||||||
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
|
assert isinstance(key, str), f"ctx.{self.lookup_type}_names used with an id, use the lookup_in_ helpers instead"
|
||||||
if isinstance(key, int):
|
|
||||||
if not self.warned:
|
|
||||||
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
|
|
||||||
self.warned = True
|
|
||||||
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
|
|
||||||
f"backwards compatibility for now. If multiple games share the same id for a "
|
|
||||||
f"{self.lookup_type}, name could be incorrect. Please use "
|
|
||||||
f"`{self.lookup_type}_names.lookup_in_game()` or "
|
|
||||||
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
|
|
||||||
return self._flat_store[key] # type: ignore
|
|
||||||
|
|
||||||
return self._game_store[key]
|
return self._game_store[key]
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
@@ -224,7 +258,7 @@ class CommonContext:
|
|||||||
return iter(self._game_store)
|
return iter(self._game_store)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return self._game_store.__repr__()
|
return repr(self._game_store)
|
||||||
|
|
||||||
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
|
def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
|
||||||
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
|
"""Returns the name for an item/location id in the context of a specific game or own game if `game` is
|
||||||
@@ -254,7 +288,6 @@ class CommonContext:
|
|||||||
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
|
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
|
||||||
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
|
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
|
||||||
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
|
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
|
||||||
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
|
|
||||||
if game == "Archipelago":
|
if game == "Archipelago":
|
||||||
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
|
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
|
||||||
# it updates in all chain maps automatically.
|
# it updates in all chain maps automatically.
|
||||||
@@ -281,38 +314,71 @@ class CommonContext:
|
|||||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||||
|
|
||||||
# remaining type info
|
# remaining type info
|
||||||
slot_info: typing.Dict[int, NetworkSlot]
|
slot_info: dict[int, NetworkSlot]
|
||||||
server_address: typing.Optional[str]
|
"""Slot Info from the server for the current connection"""
|
||||||
password: typing.Optional[str]
|
server_address: str | None
|
||||||
hint_cost: typing.Optional[int]
|
"""Autoconnect address provided by the ctx constructor"""
|
||||||
hint_points: typing.Optional[int]
|
password: str | None
|
||||||
player_names: typing.Dict[int, str]
|
"""Password used for Connecting, expected by server_auth"""
|
||||||
|
hint_cost: int | None
|
||||||
|
"""Current Hint Cost per Hint from the server"""
|
||||||
|
hint_points: int | None
|
||||||
|
"""Current avaliable Hint Points from the server"""
|
||||||
|
player_names: dict[int, str]
|
||||||
|
"""Current lookup of slot number to player display name from server (includes aliases)"""
|
||||||
|
|
||||||
finished_game: bool
|
finished_game: bool
|
||||||
|
"""
|
||||||
|
Bool to signal that status should be updated to Goal after reconnecting
|
||||||
|
to be used to ensure that a StatusUpdate packet does not get lost when disconnected
|
||||||
|
"""
|
||||||
ready: bool
|
ready: bool
|
||||||
team: typing.Optional[int]
|
"""Bool to keep track of state for the /ready command"""
|
||||||
slot: typing.Optional[int]
|
team: int | None
|
||||||
auth: typing.Optional[str]
|
"""Team number of currently connected slot"""
|
||||||
seed_name: typing.Optional[str]
|
slot: int | None
|
||||||
|
"""Slot number of currently connected slot"""
|
||||||
|
auth: str | None
|
||||||
|
"""Name used in Connect packet"""
|
||||||
|
seed_name: str | None
|
||||||
|
"""Seed name that will be validated on opening a socket if present"""
|
||||||
|
|
||||||
# locations
|
# locations
|
||||||
locations_checked: typing.Set[int] # local state
|
locations_checked: set[int]
|
||||||
locations_scouted: typing.Set[int]
|
"""
|
||||||
items_received: typing.List[NetworkItem]
|
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting
|
||||||
missing_locations: typing.Set[int] # server state
|
to be used to ensure that a LocationChecks packet does not get lost when disconnected
|
||||||
checked_locations: typing.Set[int] # server state
|
"""
|
||||||
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
locations_scouted: set[int]
|
||||||
locations_info: typing.Dict[int, NetworkItem]
|
"""
|
||||||
|
Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
|
||||||
|
to be used to ensure that a LocationScouts packet does not get lost when disconnected
|
||||||
|
"""
|
||||||
|
items_received: list[NetworkItem]
|
||||||
|
"""List of NetworkItems recieved from the server"""
|
||||||
|
missing_locations: set[int]
|
||||||
|
"""Container of Locations that are unchecked per server state"""
|
||||||
|
checked_locations: set[int]
|
||||||
|
"""Container of Locations that are checked per server state"""
|
||||||
|
server_locations: set[int]
|
||||||
|
"""Container of Locations that exist per server state; a combination between missing and checked locations"""
|
||||||
|
locations_info: dict[int, NetworkItem]
|
||||||
|
"""Dict of location id: NetworkItem info from LocationScouts request"""
|
||||||
|
|
||||||
# data storage
|
# data storage
|
||||||
stored_data: typing.Dict[str, typing.Any]
|
stored_data: dict[str, typing.Any]
|
||||||
stored_data_notification_keys: typing.Set[str]
|
"""
|
||||||
|
Data Storage values by key that were retrieved from the server
|
||||||
|
any keys subscribed to with SetNotify will be kept up to date
|
||||||
|
"""
|
||||||
|
stored_data_notification_keys: set[str]
|
||||||
|
"""Current container of watched Data Storage keys, managed by ctx.set_notify"""
|
||||||
|
|
||||||
# internals
|
# internals
|
||||||
# current message box through kvui
|
|
||||||
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
||||||
# message box reporting a loss of connection
|
"""Current message box through kvui"""
|
||||||
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
|
||||||
|
"""Message box reporting a loss of connection"""
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
||||||
# server state
|
# server state
|
||||||
@@ -356,11 +422,12 @@ class CommonContext:
|
|||||||
|
|
||||||
self.item_names = self.NameLookupDict(self, "item")
|
self.item_names = self.NameLookupDict(self, "item")
|
||||||
self.location_names = self.NameLookupDict(self, "location")
|
self.location_names = self.NameLookupDict(self, "location")
|
||||||
self.versions = {}
|
|
||||||
self.checksums = {}
|
self.checksums = {}
|
||||||
|
|
||||||
self.jsontotextparser = JSONtoTextParser(self)
|
self.jsontotextparser = JSONtoTextParser(self)
|
||||||
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
self.rawjsontotextparser = RawJSONtoTextParser(self)
|
||||||
|
if self.game:
|
||||||
|
self.checksums[self.game] = network_data_package["games"][self.game]["checksum"]
|
||||||
self.update_data_package(network_data_package)
|
self.update_data_package(network_data_package)
|
||||||
|
|
||||||
# execution
|
# execution
|
||||||
@@ -413,7 +480,8 @@ class CommonContext:
|
|||||||
await self.server.socket.close()
|
await self.server.socket.close()
|
||||||
if self.server_task is not None:
|
if self.server_task is not None:
|
||||||
await self.server_task
|
await self.server_task
|
||||||
self.ui.update_hints()
|
if self.ui:
|
||||||
|
self.ui.update_hints()
|
||||||
|
|
||||||
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
|
||||||
""" `msgs` JSON serializable """
|
""" `msgs` JSON serializable """
|
||||||
@@ -570,7 +638,6 @@ class CommonContext:
|
|||||||
|
|
||||||
# DataPackage
|
# DataPackage
|
||||||
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
async def prepare_data_package(self, relevant_games: typing.Set[str],
|
||||||
remote_date_package_versions: typing.Dict[str, int],
|
|
||||||
remote_data_package_checksums: typing.Dict[str, str]):
|
remote_data_package_checksums: typing.Dict[str, str]):
|
||||||
"""Validate that all data is present for the current multiworld.
|
"""Validate that all data is present for the current multiworld.
|
||||||
Download, assimilate and cache missing data from the server."""
|
Download, assimilate and cache missing data from the server."""
|
||||||
@@ -579,33 +646,26 @@ class CommonContext:
|
|||||||
|
|
||||||
needed_updates: typing.Set[str] = set()
|
needed_updates: typing.Set[str] = set()
|
||||||
for game in relevant_games:
|
for game in relevant_games:
|
||||||
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
|
if game not in remote_data_package_checksums:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
remote_version: int = remote_date_package_versions.get(game, 0)
|
|
||||||
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
||||||
|
|
||||||
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
|
if not remote_checksum: # custom data package and no checksum for this game
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cached_version: int = self.versions.get(game, 0)
|
|
||||||
cached_checksum: typing.Optional[str] = self.checksums.get(game)
|
cached_checksum: typing.Optional[str] = self.checksums.get(game)
|
||||||
# no action required if cached version is new enough
|
# no action required if cached version is new enough
|
||||||
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
|
if remote_checksum != cached_checksum:
|
||||||
or remote_checksum != cached_checksum:
|
|
||||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
|
||||||
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
||||||
if ((remote_checksum or remote_version <= local_version and remote_version != 0)
|
if remote_checksum == local_checksum:
|
||||||
and remote_checksum == local_checksum):
|
|
||||||
self.update_game(network_data_package["games"][game], game)
|
self.update_game(network_data_package["games"][game], game)
|
||||||
else:
|
else:
|
||||||
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
||||||
cache_version: int = cached_game.get("version", 0)
|
|
||||||
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
||||||
# download remote version if cache is not new enough
|
# download remote version if cache is not new enough
|
||||||
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
|
if remote_checksum != cache_checksum:
|
||||||
or remote_checksum != cache_checksum:
|
|
||||||
needed_updates.add(game)
|
needed_updates.add(game)
|
||||||
else:
|
else:
|
||||||
self.update_game(cached_game, game)
|
self.update_game(cached_game, game)
|
||||||
@@ -615,7 +675,6 @@ class CommonContext:
|
|||||||
def update_game(self, game_package: dict, game: str):
|
def update_game(self, game_package: dict, game: str):
|
||||||
self.item_names.update_game(game, game_package["item_name_to_id"])
|
self.item_names.update_game(game, game_package["item_name_to_id"])
|
||||||
self.location_names.update_game(game, game_package["location_name_to_id"])
|
self.location_names.update_game(game, game_package["location_name_to_id"])
|
||||||
self.versions[game] = game_package.get("version", 0)
|
|
||||||
self.checksums[game] = game_package.get("checksum")
|
self.checksums[game] = game_package.get("checksum")
|
||||||
|
|
||||||
def update_data_package(self, data_package: dict):
|
def update_data_package(self, data_package: dict):
|
||||||
@@ -624,13 +683,28 @@ class CommonContext:
|
|||||||
|
|
||||||
def consume_network_data_package(self, data_package: dict):
|
def consume_network_data_package(self, data_package: dict):
|
||||||
self.update_data_package(data_package)
|
self.update_data_package(data_package)
|
||||||
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
|
||||||
current_cache.update(data_package["games"])
|
|
||||||
Utils.persistent_store("datapackage", "games", current_cache)
|
|
||||||
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
|
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
|
||||||
for game, game_data in data_package["games"].items():
|
for game, game_data in data_package["games"].items():
|
||||||
Utils.store_data_package_for_checksum(game, game_data)
|
Utils.store_data_package_for_checksum(game, game_data)
|
||||||
|
|
||||||
|
def consume_network_item_groups(self):
|
||||||
|
data = {"item_name_groups": self.stored_data[f"_read_item_name_groups_{self.game}"]}
|
||||||
|
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
|
||||||
|
if self.game in current_cache:
|
||||||
|
current_cache[self.game].update(data)
|
||||||
|
else:
|
||||||
|
current_cache[self.game] = data
|
||||||
|
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
|
||||||
|
|
||||||
|
def consume_network_location_groups(self):
|
||||||
|
data = {"location_name_groups": self.stored_data[f"_read_location_name_groups_{self.game}"]}
|
||||||
|
current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
|
||||||
|
if self.game in current_cache:
|
||||||
|
current_cache[self.game].update(data)
|
||||||
|
else:
|
||||||
|
current_cache[self.game] = data
|
||||||
|
Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
|
||||||
|
|
||||||
# data storage
|
# data storage
|
||||||
|
|
||||||
def set_notify(self, *keys: str) -> None:
|
def set_notify(self, *keys: str) -> None:
|
||||||
@@ -889,9 +963,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||||
|
|
||||||
# update data package
|
# update data package
|
||||||
data_package_versions = args.get("datapackage_versions", {})
|
|
||||||
data_package_checksums = args.get("datapackage_checksums", {})
|
data_package_checksums = args.get("datapackage_checksums", {})
|
||||||
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
|
await ctx.prepare_data_package(set(args["games"]), data_package_checksums)
|
||||||
|
|
||||||
await ctx.server_auth(args['password'])
|
await ctx.server_auth(args['password'])
|
||||||
|
|
||||||
@@ -932,6 +1005,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.hint_points = args.get("hint_points", 0)
|
ctx.hint_points = args.get("hint_points", 0)
|
||||||
ctx.consume_players_package(args["players"])
|
ctx.consume_players_package(args["players"])
|
||||||
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
|
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
|
||||||
|
if ctx.game:
|
||||||
|
game = ctx.game
|
||||||
|
else:
|
||||||
|
game = ctx.slot_info[ctx.slot][1]
|
||||||
|
ctx.stored_data_notification_keys.add(f"_read_item_name_groups_{game}")
|
||||||
|
ctx.stored_data_notification_keys.add(f"_read_location_name_groups_{game}")
|
||||||
msgs = []
|
msgs = []
|
||||||
if ctx.locations_checked:
|
if ctx.locations_checked:
|
||||||
msgs.append({"cmd": "LocationChecks",
|
msgs.append({"cmd": "LocationChecks",
|
||||||
@@ -1012,11 +1091,19 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.stored_data.update(args["keys"])
|
ctx.stored_data.update(args["keys"])
|
||||||
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
|
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
|
||||||
ctx.ui.update_hints()
|
ctx.ui.update_hints()
|
||||||
|
if f"_read_item_name_groups_{ctx.game}" in args["keys"]:
|
||||||
|
ctx.consume_network_item_groups()
|
||||||
|
if f"_read_location_name_groups_{ctx.game}" in args["keys"]:
|
||||||
|
ctx.consume_network_location_groups()
|
||||||
|
|
||||||
elif cmd == "SetReply":
|
elif cmd == "SetReply":
|
||||||
ctx.stored_data[args["key"]] = args["value"]
|
ctx.stored_data[args["key"]] = args["value"]
|
||||||
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
|
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
|
||||||
ctx.ui.update_hints()
|
ctx.ui.update_hints()
|
||||||
|
elif f"_read_item_name_groups_{ctx.game}" == args["key"]:
|
||||||
|
ctx.consume_network_item_groups()
|
||||||
|
elif f"_read_location_name_groups_{ctx.game}" == args["key"]:
|
||||||
|
ctx.consume_network_location_groups()
|
||||||
elif args["key"].startswith("EnergyLink"):
|
elif args["key"].startswith("EnergyLink"):
|
||||||
ctx.current_energy_link_value = args["value"]
|
ctx.current_energy_link_value = args["value"]
|
||||||
if ctx.ui:
|
if ctx.ui:
|
||||||
|
|||||||
100
Dockerfile
Normal file
100
Dockerfile
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# hadolint global ignore=SC1090,SC1091
|
||||||
|
|
||||||
|
# Source
|
||||||
|
FROM scratch AS release
|
||||||
|
WORKDIR /release
|
||||||
|
ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip
|
||||||
|
|
||||||
|
# Enemizer
|
||||||
|
FROM alpine:3.21 AS enemizer
|
||||||
|
ARG TARGETARCH
|
||||||
|
WORKDIR /release
|
||||||
|
COPY --from=release /release/Enemizer.zip .
|
||||||
|
|
||||||
|
# No release for arm architecture. Skip.
|
||||||
|
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
|
apk add unzip=6.0-r15 --no-cache && \
|
||||||
|
unzip -u Enemizer.zip -d EnemizerCLI && \
|
||||||
|
chmod -R 777 EnemizerCLI; \
|
||||||
|
else touch EnemizerCLI; fi
|
||||||
|
|
||||||
|
# Cython builder stage
|
||||||
|
FROM python:3.12 AS cython-builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy and install requirements first (better caching)
|
||||||
|
COPY requirements.txt WebHostLib/requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r \
|
||||||
|
WebHostLib/requirements.txt \
|
||||||
|
"setuptools>=75,<81"
|
||||||
|
|
||||||
|
COPY _speedups.pyx .
|
||||||
|
COPY intset.h .
|
||||||
|
|
||||||
|
RUN cythonize -b -i _speedups.pyx
|
||||||
|
|
||||||
|
# Archipelago
|
||||||
|
FROM python:3.12-slim-bookworm AS archipelago
|
||||||
|
ARG TARGETARCH
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install requirements
|
||||||
|
# hadolint ignore=DL3008
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
gcc=4:12.2.0-3 \
|
||||||
|
libc6-dev \
|
||||||
|
libtk8.6=8.6.13-2 \
|
||||||
|
g++=4:12.2.0-3 \
|
||||||
|
curl && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create and activate venv
|
||||||
|
RUN python -m venv $VIRTUAL_ENV; \
|
||||||
|
. $VIRTUAL_ENV/bin/activate
|
||||||
|
|
||||||
|
# Copy and install requirements first (better caching)
|
||||||
|
COPY WebHostLib/requirements.txt WebHostLib/requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r \
|
||||||
|
WebHostLib/requirements.txt \
|
||||||
|
gunicorn==23.0.0
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
COPY --from=cython-builder /build/*.so ./
|
||||||
|
|
||||||
|
# Run ModuleUpdate
|
||||||
|
RUN python ModuleUpdate.py -y
|
||||||
|
|
||||||
|
# Purge unneeded packages
|
||||||
|
RUN apt-get purge -y \
|
||||||
|
git \
|
||||||
|
gcc \
|
||||||
|
libc6-dev \
|
||||||
|
g++ && \
|
||||||
|
apt-get autoremove -y
|
||||||
|
|
||||||
|
# Copy necessary components
|
||||||
|
COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI
|
||||||
|
|
||||||
|
# No release for arm architecture. Skip.
|
||||||
|
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
|
cp -r /tmp/EnemizerCLI EnemizerCLI; \
|
||||||
|
fi; \
|
||||||
|
rm -rf /tmp/EnemizerCLI
|
||||||
|
|
||||||
|
# Define health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:${PORT:-80} || exit 1
|
||||||
|
|
||||||
|
# Ensure no runtime ModuleUpdate.
|
||||||
|
ENV SKIP_REQUIREMENTS_UPDATE=true
|
||||||
|
|
||||||
|
ENTRYPOINT [ "python", "WebHost.py" ]
|
||||||
267
FF1Client.py
267
FF1Client.py
@@ -1,267 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import copy
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from asyncio import StreamReader, StreamWriter
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
|
|
||||||
import Utils
|
|
||||||
from Utils import async_start
|
|
||||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
|
||||||
get_base_parser
|
|
||||||
|
|
||||||
SYSTEM_MESSAGE_ID = 0
|
|
||||||
|
|
||||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
|
|
||||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
|
|
||||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
|
|
||||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
|
||||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
|
||||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
|
||||||
|
|
||||||
DISPLAY_MSGS = True
|
|
||||||
|
|
||||||
|
|
||||||
class FF1CommandProcessor(ClientCommandProcessor):
|
|
||||||
def __init__(self, ctx: CommonContext):
|
|
||||||
super().__init__(ctx)
|
|
||||||
|
|
||||||
def _cmd_nes(self):
|
|
||||||
"""Check NES Connection State"""
|
|
||||||
if isinstance(self.ctx, FF1Context):
|
|
||||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
|
||||||
|
|
||||||
def _cmd_toggle_msgs(self):
|
|
||||||
"""Toggle displaying messages in EmuHawk"""
|
|
||||||
global DISPLAY_MSGS
|
|
||||||
DISPLAY_MSGS = not DISPLAY_MSGS
|
|
||||||
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
|
||||||
|
|
||||||
|
|
||||||
class FF1Context(CommonContext):
|
|
||||||
command_processor = FF1CommandProcessor
|
|
||||||
game = 'Final Fantasy'
|
|
||||||
items_handling = 0b111 # full remote
|
|
||||||
|
|
||||||
def __init__(self, server_address, password):
|
|
||||||
super().__init__(server_address, password)
|
|
||||||
self.nes_streams: (StreamReader, StreamWriter) = None
|
|
||||||
self.nes_sync_task = None
|
|
||||||
self.messages = {}
|
|
||||||
self.locations_array = None
|
|
||||||
self.nes_status = CONNECTION_INITIAL_STATUS
|
|
||||||
self.awaiting_rom = False
|
|
||||||
self.display_msgs = True
|
|
||||||
|
|
||||||
async def server_auth(self, password_requested: bool = False):
|
|
||||||
if password_requested and not self.password:
|
|
||||||
await super(FF1Context, self).server_auth(password_requested)
|
|
||||||
if not self.auth:
|
|
||||||
self.awaiting_rom = True
|
|
||||||
logger.info('Awaiting connection to NES to get Player information')
|
|
||||||
return
|
|
||||||
|
|
||||||
await self.send_connect()
|
|
||||||
|
|
||||||
def _set_message(self, msg: str, msg_id: int):
|
|
||||||
if DISPLAY_MSGS:
|
|
||||||
self.messages[time.time(), msg_id] = msg
|
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
|
||||||
if cmd == 'Connected':
|
|
||||||
async_start(parse_locations(self.locations_array, self, True))
|
|
||||||
elif cmd == 'Print':
|
|
||||||
msg = args['text']
|
|
||||||
if ': !' not in msg:
|
|
||||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
|
||||||
|
|
||||||
def on_print_json(self, args: dict):
|
|
||||||
if self.ui:
|
|
||||||
self.ui.print_json(copy.deepcopy(args["data"]))
|
|
||||||
else:
|
|
||||||
text = self.jsontotextparser(copy.deepcopy(args["data"]))
|
|
||||||
logger.info(text)
|
|
||||||
relevant = args.get("type", None) in {"Hint", "ItemSend"}
|
|
||||||
if relevant:
|
|
||||||
item = args["item"]
|
|
||||||
# goes to this world
|
|
||||||
if self.slot_concerns_self(args["receiving"]):
|
|
||||||
relevant = True
|
|
||||||
# found in this world
|
|
||||||
elif self.slot_concerns_self(item.player):
|
|
||||||
relevant = True
|
|
||||||
# not related
|
|
||||||
else:
|
|
||||||
relevant = False
|
|
||||||
if relevant:
|
|
||||||
item = args["item"]
|
|
||||||
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
|
|
||||||
self._set_message(msg, item.item)
|
|
||||||
|
|
||||||
def run_gui(self):
|
|
||||||
from kvui import GameManager
|
|
||||||
|
|
||||||
class FF1Manager(GameManager):
|
|
||||||
logging_pairs = [
|
|
||||||
("Client", "Archipelago")
|
|
||||||
]
|
|
||||||
base_title = "Archipelago Final Fantasy 1 Client"
|
|
||||||
|
|
||||||
self.ui = FF1Manager(self)
|
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
|
||||||
|
|
||||||
|
|
||||||
def get_payload(ctx: FF1Context):
|
|
||||||
current_time = time.time()
|
|
||||||
return json.dumps(
|
|
||||||
{
|
|
||||||
"items": [item.item for item in ctx.items_received],
|
|
||||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
|
||||||
if key[0] > current_time - 10}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
|
|
||||||
if locations_array == ctx.locations_array and not force:
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
# print("New values")
|
|
||||||
ctx.locations_array = locations_array
|
|
||||||
locations_checked = []
|
|
||||||
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
|
|
||||||
await ctx.send_msgs([
|
|
||||||
{"cmd": "StatusUpdate",
|
|
||||||
"status": 30}
|
|
||||||
])
|
|
||||||
ctx.finished_game = True
|
|
||||||
for location in ctx.missing_locations:
|
|
||||||
# index will be - 0x100 or 0x200
|
|
||||||
index = location
|
|
||||||
if location < 0x200:
|
|
||||||
# Location is a chest
|
|
||||||
index -= 0x100
|
|
||||||
flag = 0x04
|
|
||||||
else:
|
|
||||||
# Location is an NPC
|
|
||||||
index -= 0x200
|
|
||||||
flag = 0x02
|
|
||||||
|
|
||||||
# print(f"Location: {ctx.location_names[location]}")
|
|
||||||
# print(f"Index: {str(hex(index))}")
|
|
||||||
# print(f"value: {locations_array[index] & flag != 0}")
|
|
||||||
if locations_array[index] & flag != 0:
|
|
||||||
locations_checked.append(location)
|
|
||||||
if locations_checked:
|
|
||||||
# print([ctx.location_names[location] for location in locations_checked])
|
|
||||||
await ctx.send_msgs([
|
|
||||||
{"cmd": "LocationChecks",
|
|
||||||
"locations": locations_checked}
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
async def nes_sync_task(ctx: FF1Context):
|
|
||||||
logger.info("Starting nes connector. Use /nes for status information")
|
|
||||||
while not ctx.exit_event.is_set():
|
|
||||||
error_status = None
|
|
||||||
if ctx.nes_streams:
|
|
||||||
(reader, writer) = ctx.nes_streams
|
|
||||||
msg = get_payload(ctx).encode()
|
|
||||||
writer.write(msg)
|
|
||||||
writer.write(b'\n')
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
|
||||||
try:
|
|
||||||
# Data will return a dict with up to two fields:
|
|
||||||
# 1. A keepalive response of the Players Name (always)
|
|
||||||
# 2. An array representing the memory values of the locations area (if in game)
|
|
||||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
|
||||||
data_decoded = json.loads(data.decode())
|
|
||||||
# print(data_decoded)
|
|
||||||
if ctx.game is not None and 'locations' in data_decoded:
|
|
||||||
# Not just a keep alive ping, parse
|
|
||||||
async_start(parse_locations(data_decoded['locations'], ctx, False))
|
|
||||||
if not ctx.auth:
|
|
||||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
|
||||||
if ctx.auth == '':
|
|
||||||
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
|
|
||||||
"the ROM using the same link but adding your slot name")
|
|
||||||
if ctx.awaiting_rom:
|
|
||||||
await ctx.server_auth(False)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.debug("Read Timed Out, Reconnecting")
|
|
||||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.nes_streams = None
|
|
||||||
except ConnectionResetError as e:
|
|
||||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.nes_streams = None
|
|
||||||
except TimeoutError:
|
|
||||||
logger.debug("Connection Timed Out, Reconnecting")
|
|
||||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.nes_streams = None
|
|
||||||
except ConnectionResetError:
|
|
||||||
logger.debug("Connection Lost, Reconnecting")
|
|
||||||
error_status = CONNECTION_RESET_STATUS
|
|
||||||
writer.close()
|
|
||||||
ctx.nes_streams = None
|
|
||||||
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
|
|
||||||
if not error_status:
|
|
||||||
logger.info("Successfully Connected to NES")
|
|
||||||
ctx.nes_status = CONNECTION_CONNECTED_STATUS
|
|
||||||
else:
|
|
||||||
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
|
|
||||||
elif error_status:
|
|
||||||
ctx.nes_status = error_status
|
|
||||||
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
logger.debug("Attempting to connect to NES")
|
|
||||||
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
|
|
||||||
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
|
|
||||||
except TimeoutError:
|
|
||||||
logger.debug("Connection Timed Out, Trying Again")
|
|
||||||
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
|
|
||||||
continue
|
|
||||||
except ConnectionRefusedError:
|
|
||||||
logger.debug("Connection Refused, Trying Again")
|
|
||||||
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Text Mode to use !hint and such with games that have no text entry
|
|
||||||
Utils.init_logging("FF1Client")
|
|
||||||
|
|
||||||
options = Utils.get_options()
|
|
||||||
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
|
|
||||||
|
|
||||||
async def main(args):
|
|
||||||
ctx = FF1Context(args.connect, args.password)
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
|
||||||
if gui_enabled:
|
|
||||||
ctx.run_gui()
|
|
||||||
ctx.run_cli()
|
|
||||||
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
|
|
||||||
|
|
||||||
await ctx.exit_event.wait()
|
|
||||||
ctx.server_address = None
|
|
||||||
|
|
||||||
await ctx.shutdown()
|
|
||||||
|
|
||||||
if ctx.nes_sync_task:
|
|
||||||
await ctx.nes_sync_task
|
|
||||||
|
|
||||||
|
|
||||||
import colorama
|
|
||||||
|
|
||||||
parser = get_base_parser()
|
|
||||||
args = parser.parse_args()
|
|
||||||
colorama.just_fix_windows_console()
|
|
||||||
|
|
||||||
asyncio.run(main(args))
|
|
||||||
colorama.deinit()
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ModuleUpdate
|
|
||||||
ModuleUpdate.update()
|
|
||||||
|
|
||||||
from worlds.factorio.Client import check_stdin, launch
|
|
||||||
import Utils
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
|
||||||
check_stdin()
|
|
||||||
launch()
|
|
||||||
493
Fill.py
493
Fill.py
@@ -4,7 +4,7 @@ import logging
|
|||||||
import typing
|
import typing
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
|
|
||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
|
||||||
from Options import Accessibility
|
from Options import Accessibility
|
||||||
|
|
||||||
from worlds.AutoWorld import call_all
|
from worlds.AutoWorld import call_all
|
||||||
@@ -75,9 +75,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
items_to_place.append(reachable_items[next_player].pop())
|
items_to_place.append(reachable_items[next_player].pop())
|
||||||
|
|
||||||
for item in items_to_place:
|
for item in items_to_place:
|
||||||
for p, pool_item in enumerate(item_pool):
|
# The items added into `reachable_items` are placed starting from the end of each deque in
|
||||||
|
# `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`.
|
||||||
|
for p, pool_item in enumerate(reversed(item_pool), start=1):
|
||||||
if pool_item is item:
|
if pool_item is item:
|
||||||
item_pool.pop(p)
|
del item_pool[-p]
|
||||||
break
|
break
|
||||||
|
|
||||||
maximum_exploration_state = sweep_from_pool(
|
maximum_exploration_state = sweep_from_pool(
|
||||||
@@ -98,7 +100,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
# if minimal accessibility, only check whether location is reachable if game not beatable
|
# if minimal accessibility, only check whether location is reachable if game not beatable
|
||||||
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
|
||||||
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
|
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
|
||||||
item_to_place.player) \
|
item_to_place.player) \
|
||||||
if single_player_placement else not has_beaten_game
|
if single_player_placement else not has_beaten_game
|
||||||
else:
|
else:
|
||||||
perform_access_check = True
|
perform_access_check = True
|
||||||
@@ -114,6 +116,13 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
else:
|
else:
|
||||||
# we filled all reachable spots.
|
# we filled all reachable spots.
|
||||||
if swap:
|
if swap:
|
||||||
|
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
|
||||||
|
# swap state, instead of sweeping from `base_state` each time.
|
||||||
|
previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque()
|
||||||
|
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
|
||||||
|
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
|
||||||
|
max_swap_base_state_cache_length = 3
|
||||||
|
|
||||||
# try swapping this item with previously placed items in a safe way then in an unsafe way
|
# try swapping this item with previously placed items in a safe way then in an unsafe way
|
||||||
swap_attempts = ((i, location, unsafe)
|
swap_attempts = ((i, location, unsafe)
|
||||||
for unsafe in (False, True)
|
for unsafe in (False, True)
|
||||||
@@ -128,40 +137,50 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
|||||||
|
|
||||||
location.item = None
|
location.item = None
|
||||||
placed_item.location = None
|
placed_item.location = None
|
||||||
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
|
|
||||||
multiworld.get_filled_locations(item.player)
|
for previous_safe_swap_state in previous_safe_swap_state_cache:
|
||||||
if single_player_placement else None)
|
# If a state has already checked the location of the swap, then it cannot be used.
|
||||||
|
if location not in previous_safe_swap_state.advancements:
|
||||||
|
# Previous swap states will have collected all items in `item_pool`, so the new
|
||||||
|
# `swap_state` can skip having to collect them again.
|
||||||
|
# Previous swap states will also have already checked many locations, making the sweep
|
||||||
|
# faster.
|
||||||
|
swap_state = sweep_from_pool(previous_safe_swap_state, (placed_item,) if unsafe else (),
|
||||||
|
multiworld.get_filled_locations(item.player)
|
||||||
|
if single_player_placement else None)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# No previous swap_state was usable as a base state to sweep from, so create a new one.
|
||||||
|
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
|
||||||
|
multiworld.get_filled_locations(item.player)
|
||||||
|
if single_player_placement else None)
|
||||||
|
# Unsafe states should not be added to the cache because they have collected `placed_item`.
|
||||||
|
if not unsafe:
|
||||||
|
if len(previous_safe_swap_state_cache) >= max_swap_base_state_cache_length:
|
||||||
|
# Remove the oldest cached state.
|
||||||
|
previous_safe_swap_state_cache.pop()
|
||||||
|
# Add the new state to the start of the cache.
|
||||||
|
previous_safe_swap_state_cache.appendleft(swap_state)
|
||||||
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
|
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
|
||||||
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
|
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
|
||||||
# to clean that up later, so there is a chance generation fails.
|
# to clean that up later, so there is a chance generation fails.
|
||||||
if (not single_player_placement or location.player == item_to_place.player) \
|
if (not single_player_placement or location.player == item_to_place.player) \
|
||||||
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
||||||
|
# Add this item to the existing placement, and
|
||||||
|
# add the old item to the back of the queue
|
||||||
|
spot_to_fill = placements.pop(i)
|
||||||
|
|
||||||
# Verify placing this item won't reduce available locations, which would be a useless swap.
|
swap_count += 1
|
||||||
prev_state = swap_state.copy()
|
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
|
||||||
prev_loc_count = len(
|
|
||||||
multiworld.get_reachable_locations(prev_state))
|
|
||||||
|
|
||||||
swap_state.collect(item_to_place, True)
|
reachable_items[placed_item.player].appendleft(
|
||||||
new_loc_count = len(
|
placed_item)
|
||||||
multiworld.get_reachable_locations(swap_state))
|
item_pool.append(placed_item)
|
||||||
|
|
||||||
if new_loc_count >= prev_loc_count:
|
# cleanup at the end to hopefully get better errors
|
||||||
# Add this item to the existing placement, and
|
cleanup_required = True
|
||||||
# add the old item to the back of the queue
|
|
||||||
spot_to_fill = placements.pop(i)
|
|
||||||
|
|
||||||
swap_count += 1
|
break
|
||||||
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
|
|
||||||
|
|
||||||
reachable_items[placed_item.player].appendleft(
|
|
||||||
placed_item)
|
|
||||||
item_pool.append(placed_item)
|
|
||||||
|
|
||||||
# cleanup at the end to hopefully get better errors
|
|
||||||
cleanup_required = True
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
# Item can't be placed here, restore original item
|
# Item can't be placed here, restore original item
|
||||||
location.item = placed_item
|
location.item = placed_item
|
||||||
@@ -240,7 +259,7 @@ def remaining_fill(multiworld: MultiWorld,
|
|||||||
unplaced_items: typing.List[Item] = []
|
unplaced_items: typing.List[Item] = []
|
||||||
placements: typing.List[Location] = []
|
placements: typing.List[Location] = []
|
||||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||||
total = min(len(itempool), len(locations))
|
total = min(len(itempool), len(locations))
|
||||||
placed = 0
|
placed = 0
|
||||||
|
|
||||||
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
|
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
|
||||||
@@ -339,19 +358,26 @@ def fast_fill(multiworld: MultiWorld,
|
|||||||
return item_pool[placing:], fill_locations[placing:]
|
return item_pool[placing:], fill_locations[placing:]
|
||||||
|
|
||||||
|
|
||||||
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
|
def accessibility_corrections(multiworld: MultiWorld,
|
||||||
|
state: CollectionState,
|
||||||
|
locations: list[Location],
|
||||||
|
pool: list[Item] | None = None) -> None:
|
||||||
|
if pool is None:
|
||||||
|
pool = []
|
||||||
maximum_exploration_state = sweep_from_pool(state, pool)
|
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||||
minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
|
minimal_players = {player for player in multiworld.player_ids if
|
||||||
unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
|
multiworld.worlds[player].options.accessibility == "minimal"}
|
||||||
|
unreachable_locations = [location for location in multiworld.get_locations() if
|
||||||
|
location.player in minimal_players and
|
||||||
not location.can_reach(maximum_exploration_state)]
|
not location.can_reach(maximum_exploration_state)]
|
||||||
for location in unreachable_locations:
|
for location in unreachable_locations:
|
||||||
if (location.item is not None and location.item.advancement and location.address is not None and not
|
if (location.item is not None and location.item.advancement and location.address is not None and not
|
||||||
location.locked and location.item.player not in minimal_players):
|
location.locked and location.item.player not in minimal_players):
|
||||||
pool.append(location.item)
|
pool.append(location.item)
|
||||||
state.remove(location.item)
|
|
||||||
location.item = None
|
location.item = None
|
||||||
if location in state.advancements:
|
if location in state.advancements:
|
||||||
state.advancements.remove(location)
|
state.advancements.remove(location)
|
||||||
|
state.remove(location.item)
|
||||||
locations.append(location)
|
locations.append(location)
|
||||||
if pool and locations:
|
if pool and locations:
|
||||||
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
|
||||||
@@ -363,7 +389,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState,
|
|||||||
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
|
||||||
if unreachable_locations:
|
if unreachable_locations:
|
||||||
def forbid_important_item_rule(item: Item):
|
def forbid_important_item_rule(item: Item):
|
||||||
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
|
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
|
||||||
|
|
||||||
for location in unreachable_locations:
|
for location in unreachable_locations:
|
||||||
add_item_rule(location, forbid_important_item_rule)
|
add_item_rule(location, forbid_important_item_rule)
|
||||||
@@ -457,6 +483,12 @@ def distribute_early_items(multiworld: MultiWorld,
|
|||||||
|
|
||||||
def distribute_items_restrictive(multiworld: MultiWorld,
|
def distribute_items_restrictive(multiworld: MultiWorld,
|
||||||
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
||||||
|
assert all(item.location is None for item in multiworld.itempool), (
|
||||||
|
"At the start of distribute_items_restrictive, "
|
||||||
|
"there are items in the multiworld itempool that are already placed on locations:\n"
|
||||||
|
f"{[(item.location, item) for item in multiworld.itempool if item.location is not None]}"
|
||||||
|
)
|
||||||
|
|
||||||
fill_locations = sorted(multiworld.get_unfilled_locations())
|
fill_locations = sorted(multiworld.get_unfilled_locations())
|
||||||
multiworld.random.shuffle(fill_locations)
|
multiworld.random.shuffle(fill_locations)
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
@@ -499,29 +531,64 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
single_player = multiworld.players == 1 and not multiworld.groups
|
single_player = multiworld.players == 1 and not multiworld.groups
|
||||||
|
|
||||||
if prioritylocations:
|
if prioritylocations:
|
||||||
|
regular_progression = []
|
||||||
|
deprioritized_progression = []
|
||||||
|
for item in progitempool:
|
||||||
|
if item.deprioritized:
|
||||||
|
deprioritized_progression.append(item)
|
||||||
|
else:
|
||||||
|
regular_progression.append(item)
|
||||||
|
|
||||||
# "priority fill"
|
# "priority fill"
|
||||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
# try without deprioritized items in the mix at all. This means they need to be collected into state first.
|
||||||
|
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,
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
name="Priority", one_item_per_player=True, allow_partial=True)
|
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
|
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
||||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
# deprioritized items are still not in the mix, so they need to be collected into state first.
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
# allow_partial should only be set if there is deprioritized progression to fall back on.
|
||||||
name="Priority Retry", one_item_per_player=False)
|
priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
|
||||||
|
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
|
||||||
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
|
name="Priority Retry", one_item_per_player=False,
|
||||||
|
allow_partial=bool(deprioritized_progression))
|
||||||
|
|
||||||
|
if prioritylocations and deprioritized_progression:
|
||||||
|
# There are no more regular progression items that can be placed on any priority locations.
|
||||||
|
# We'd still prefer to place deprioritized progression items on priority locations over filler items.
|
||||||
|
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
|
||||||
|
priority_retry_2_state = sweep_from_pool(multiworld.state, regular_progression)
|
||||||
|
fill_restrictive(multiworld, priority_retry_2_state, prioritylocations, deprioritized_progression,
|
||||||
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
|
name="Priority Retry 2", one_item_per_player=True, allow_partial=True)
|
||||||
|
|
||||||
|
if prioritylocations and deprioritized_progression:
|
||||||
|
# retry with deprioritized items AND without one_item_per_player optimisation
|
||||||
|
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
|
||||||
|
priority_retry_3_state = sweep_from_pool(multiworld.state, regular_progression)
|
||||||
|
fill_restrictive(multiworld, priority_retry_3_state, prioritylocations, deprioritized_progression,
|
||||||
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
|
name="Priority Retry 3", one_item_per_player=False)
|
||||||
|
|
||||||
|
# restore original order of progitempool
|
||||||
|
progitempool[:] = [item for item in progitempool if not item.location]
|
||||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||||
defaultlocations = prioritylocations + defaultlocations
|
defaultlocations = prioritylocations + defaultlocations
|
||||||
|
|
||||||
if progitempool:
|
if progitempool:
|
||||||
# "advancement/progression fill"
|
# "advancement/progression fill"
|
||||||
|
maximum_exploration_state = sweep_from_pool(multiworld.state)
|
||||||
if panic_method == "swap":
|
if panic_method == "swap":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True,
|
||||||
name="Progression", single_player_placement=single_player)
|
name="Progression", single_player_placement=single_player)
|
||||||
elif panic_method == "raise":
|
elif panic_method == "raise":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
||||||
name="Progression", single_player_placement=single_player)
|
name="Progression", single_player_placement=single_player)
|
||||||
elif panic_method == "start_inventory":
|
elif panic_method == "start_inventory":
|
||||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
|
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
|
||||||
allow_partial=True, name="Progression", single_player_placement=single_player)
|
allow_partial=True, name="Progression", single_player_placement=single_player)
|
||||||
if progitempool:
|
if progitempool:
|
||||||
for item in progitempool:
|
for item in progitempool:
|
||||||
@@ -672,9 +739,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
if multiworld.worlds[player].options.progression_balancing > 0
|
if multiworld.worlds[player].options.progression_balancing > 0
|
||||||
}
|
}
|
||||||
if not balanceable_players:
|
if not balanceable_players:
|
||||||
logging.info('Skipping multiworld progression balancing.')
|
logging.info("Skipping multiworld progression balancing.")
|
||||||
else:
|
else:
|
||||||
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
|
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
|
||||||
logging.debug(balanceable_players)
|
logging.debug(balanceable_players)
|
||||||
state: CollectionState = CollectionState(multiworld)
|
state: CollectionState = CollectionState(multiworld)
|
||||||
checked_locations: typing.Set[Location] = set()
|
checked_locations: typing.Set[Location] = set()
|
||||||
@@ -772,7 +839,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
if player in threshold_percentages):
|
if player in threshold_percentages):
|
||||||
break
|
break
|
||||||
elif not balancing_sphere:
|
elif not balancing_sphere:
|
||||||
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
|
||||||
# Gather a set of locations which we can swap items into
|
# Gather a set of locations which we can swap items into
|
||||||
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
|
||||||
for l in unchecked_locations:
|
for l in unchecked_locations:
|
||||||
@@ -788,8 +855,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
|
|||||||
testing = items_to_test.pop()
|
testing = items_to_test.pop()
|
||||||
reducing_state = state.copy()
|
reducing_state = state.copy()
|
||||||
for location in itertools.chain((
|
for location in itertools.chain((
|
||||||
l for l in items_to_replace
|
l for l in items_to_replace
|
||||||
if l.item.player == player
|
if l.item.player == player
|
||||||
), items_to_test):
|
), items_to_test):
|
||||||
reducing_state.collect(location.item, True, location)
|
reducing_state.collect(location.item, True, location)
|
||||||
|
|
||||||
@@ -862,52 +929,30 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
|
|||||||
location_2.item.location = location_2
|
location_2.item.location = location_2
|
||||||
|
|
||||||
|
|
||||||
def distribute_planned(multiworld: MultiWorld) -> None:
|
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]:
|
||||||
def warn(warning: str, force: typing.Union[bool, str]) -> None:
|
def warn(warning: str, force: bool | str) -> None:
|
||||||
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
|
if isinstance(force, bool):
|
||||||
logging.warning(f'{warning}')
|
logging.warning(f"{warning}")
|
||||||
else:
|
else:
|
||||||
logging.debug(f'{warning}')
|
logging.debug(f"{warning}")
|
||||||
|
|
||||||
def failed(warning: str, force: typing.Union[bool, str]) -> None:
|
def failed(warning: str, force: bool | str) -> None:
|
||||||
if force in [True, 'fail', 'failure']:
|
if force is True:
|
||||||
raise Exception(warning)
|
raise Exception(warning)
|
||||||
else:
|
else:
|
||||||
warn(warning, force)
|
warn(warning, force)
|
||||||
|
|
||||||
swept_state = multiworld.state.copy()
|
|
||||||
swept_state.sweep_for_advancements()
|
|
||||||
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
|
||||||
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
|
||||||
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
|
|
||||||
for loc in multiworld.get_unfilled_locations():
|
|
||||||
if loc in reachable:
|
|
||||||
early_locations[loc.player].append(loc.name)
|
|
||||||
else: # not reachable with swept state
|
|
||||||
non_early_locations[loc.player].append(loc.name)
|
|
||||||
|
|
||||||
world_name_lookup = multiworld.world_name_lookup
|
world_name_lookup = multiworld.world_name_lookup
|
||||||
|
|
||||||
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
|
plando_blocks: dict[int, list[PlandoItemBlock]] = dict()
|
||||||
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
|
player_ids: set[int] = set(multiworld.player_ids)
|
||||||
player_ids = set(multiworld.player_ids)
|
|
||||||
for player in player_ids:
|
for player in player_ids:
|
||||||
for block in multiworld.plando_items[player]:
|
plando_blocks[player] = []
|
||||||
block['player'] = player
|
for block in multiworld.worlds[player].options.plando_items:
|
||||||
if 'force' not in block:
|
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
|
||||||
block['force'] = 'silent'
|
target_world = block.world
|
||||||
if 'from_pool' not in block:
|
|
||||||
block['from_pool'] = True
|
|
||||||
elif not isinstance(block['from_pool'], bool):
|
|
||||||
from_pool_type = type(block['from_pool'])
|
|
||||||
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
|
|
||||||
if 'world' not in block:
|
|
||||||
target_world = False
|
|
||||||
else:
|
|
||||||
target_world = block['world']
|
|
||||||
|
|
||||||
if target_world is False or multiworld.players == 1: # target own world
|
if target_world is False or multiworld.players == 1: # target own world
|
||||||
worlds: typing.Set[int] = {player}
|
worlds: set[int] = {player}
|
||||||
elif target_world is True: # target any worlds besides own
|
elif target_world is True: # target any worlds besides own
|
||||||
worlds = set(multiworld.player_ids) - {player}
|
worlds = set(multiworld.player_ids) - {player}
|
||||||
elif target_world is None: # target all worlds
|
elif target_world is None: # target all worlds
|
||||||
@@ -916,173 +961,201 @@ def distribute_planned(multiworld: MultiWorld) -> None:
|
|||||||
worlds = set()
|
worlds = set()
|
||||||
for listed_world in target_world:
|
for listed_world in target_world:
|
||||||
if listed_world not in world_name_lookup:
|
if listed_world not in world_name_lookup:
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.",
|
||||||
block['force'])
|
block.force)
|
||||||
continue
|
continue
|
||||||
worlds.add(world_name_lookup[listed_world])
|
worlds.add(world_name_lookup[listed_world])
|
||||||
elif type(target_world) == int: # target world by slot number
|
elif type(target_world) == int: # target world by slot number
|
||||||
if target_world not in range(1, multiworld.players + 1):
|
if target_world not in range(1, multiworld.players + 1):
|
||||||
failed(
|
failed(
|
||||||
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
|
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
|
||||||
block['force'])
|
block.force)
|
||||||
continue
|
continue
|
||||||
worlds = {target_world}
|
worlds = {target_world}
|
||||||
else: # target world by slot name
|
else: # target world by slot name
|
||||||
if target_world not in world_name_lookup:
|
if target_world not in world_name_lookup:
|
||||||
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||||
block['force'])
|
block.force)
|
||||||
continue
|
continue
|
||||||
worlds = {world_name_lookup[target_world]}
|
worlds = {world_name_lookup[target_world]}
|
||||||
block['world'] = worlds
|
new_block.worlds = worlds
|
||||||
|
|
||||||
items: block_value = []
|
items: list[str] | dict[str, typing.Any] = block.items
|
||||||
if "items" in block:
|
|
||||||
items = block["items"]
|
|
||||||
if 'count' not in block:
|
|
||||||
block['count'] = False
|
|
||||||
elif "item" in block:
|
|
||||||
items = block["item"]
|
|
||||||
if 'count' not in block:
|
|
||||||
block['count'] = 1
|
|
||||||
else:
|
|
||||||
failed("You must specify at least one item to place items with plando.", block['force'])
|
|
||||||
continue
|
|
||||||
if isinstance(items, dict):
|
if isinstance(items, dict):
|
||||||
item_list: typing.List[str] = []
|
item_list: list[str] = []
|
||||||
for key, value in items.items():
|
for key, value in items.items():
|
||||||
if value is True:
|
if value is True:
|
||||||
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
|
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
|
||||||
item_list += [key] * value
|
item_list += [key] * value
|
||||||
items = item_list
|
items = item_list
|
||||||
if isinstance(items, str):
|
new_block.items = items
|
||||||
items = [items]
|
|
||||||
block['items'] = items
|
|
||||||
|
|
||||||
locations: block_value = []
|
locations: list[str] = block.locations
|
||||||
if 'location' in block:
|
|
||||||
locations = block['location'] # just allow 'location' to keep old yamls compatible
|
|
||||||
elif 'locations' in block:
|
|
||||||
locations = block['locations']
|
|
||||||
if isinstance(locations, str):
|
if isinstance(locations, str):
|
||||||
locations = [locations]
|
locations = [locations]
|
||||||
|
|
||||||
if isinstance(locations, dict):
|
resolved_locations: list[Location] = []
|
||||||
location_list = []
|
for target_player in worlds:
|
||||||
for key, value in locations.items():
|
locations_from_groups: list[str] = []
|
||||||
location_list += [key] * value
|
world_locations = multiworld.get_unfilled_locations(target_player)
|
||||||
locations = location_list
|
for group in multiworld.worlds[target_player].location_name_groups:
|
||||||
|
if group in locations:
|
||||||
|
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
|
||||||
|
resolved_locations.extend(location for location in world_locations
|
||||||
|
if location.name in [*locations, *locations_from_groups])
|
||||||
|
new_block.locations = sorted(dict.fromkeys(locations))
|
||||||
|
new_block.resolved_locations = sorted(set(resolved_locations))
|
||||||
|
|
||||||
|
count = block.count
|
||||||
|
if not count:
|
||||||
|
count = (min(len(new_block.items), len(new_block.resolved_locations))
|
||||||
|
if new_block.resolved_locations else len(new_block.items))
|
||||||
|
if isinstance(count, int):
|
||||||
|
count = {"min": count, "max": count}
|
||||||
|
if "min" not in count:
|
||||||
|
count["min"] = 0
|
||||||
|
if "max" not in count:
|
||||||
|
count["max"] = (min(len(new_block.items), len(new_block.resolved_locations))
|
||||||
|
if new_block.resolved_locations else len(new_block.items))
|
||||||
|
|
||||||
|
|
||||||
|
new_block.count = count
|
||||||
|
plando_blocks[player].append(new_block)
|
||||||
|
|
||||||
|
return plando_blocks
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_early_locations_for_planned(multiworld: MultiWorld):
|
||||||
|
def warn(warning: str, force: bool | str) -> None:
|
||||||
|
if isinstance(force, bool):
|
||||||
|
logging.warning(f"{warning}")
|
||||||
|
else:
|
||||||
|
logging.debug(f"{warning}")
|
||||||
|
|
||||||
|
def failed(warning: str, force: bool | str) -> None:
|
||||||
|
if force is True:
|
||||||
|
raise Exception(warning)
|
||||||
|
else:
|
||||||
|
warn(warning, force)
|
||||||
|
|
||||||
|
swept_state = multiworld.state.copy()
|
||||||
|
swept_state.sweep_for_advancements()
|
||||||
|
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
|
||||||
|
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
||||||
|
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
|
||||||
|
for loc in multiworld.get_unfilled_locations():
|
||||||
|
if loc in reachable:
|
||||||
|
early_locations[loc.player].append(loc)
|
||||||
|
else: # not reachable with swept state
|
||||||
|
non_early_locations[loc.player].append(loc)
|
||||||
|
|
||||||
|
for player in multiworld.plando_item_blocks:
|
||||||
|
removed = []
|
||||||
|
for block in multiworld.plando_item_blocks[player]:
|
||||||
|
locations = block.locations
|
||||||
|
resolved_locations = block.resolved_locations
|
||||||
|
worlds = block.worlds
|
||||||
if "early_locations" in locations:
|
if "early_locations" in locations:
|
||||||
locations.remove("early_locations")
|
|
||||||
for target_player in worlds:
|
for target_player in worlds:
|
||||||
locations += early_locations[target_player]
|
resolved_locations += early_locations[target_player]
|
||||||
if "non_early_locations" in locations:
|
if "non_early_locations" in locations:
|
||||||
locations.remove("non_early_locations")
|
|
||||||
for target_player in worlds:
|
for target_player in worlds:
|
||||||
locations += non_early_locations[target_player]
|
resolved_locations += non_early_locations[target_player]
|
||||||
|
|
||||||
block['locations'] = list(dict.fromkeys(locations))
|
if block.count["max"] > len(block.items):
|
||||||
|
count = block.count["max"]
|
||||||
|
failed(f"Plando count {count} greater than items specified", block.force)
|
||||||
|
block.count["max"] = len(block.items)
|
||||||
|
if block.count["min"] > len(block.items):
|
||||||
|
block.count["min"] = len(block.items)
|
||||||
|
if block.count["max"] > len(block.resolved_locations) > 0:
|
||||||
|
count = block.count["max"]
|
||||||
|
failed(f"Plando count {count} greater than locations specified", block.force)
|
||||||
|
block.count["max"] = len(block.resolved_locations)
|
||||||
|
if block.count["min"] > len(block.resolved_locations):
|
||||||
|
block.count["min"] = len(block.resolved_locations)
|
||||||
|
block.count["target"] = multiworld.random.randint(block.count["min"],
|
||||||
|
block.count["max"])
|
||||||
|
|
||||||
if not block['count']:
|
if not block.count["target"]:
|
||||||
block['count'] = (min(len(block['items']), len(block['locations'])) if
|
removed.append(block)
|
||||||
len(block['locations']) > 0 else len(block['items']))
|
|
||||||
if isinstance(block['count'], int):
|
|
||||||
block['count'] = {'min': block['count'], 'max': block['count']}
|
|
||||||
if 'min' not in block['count']:
|
|
||||||
block['count']['min'] = 0
|
|
||||||
if 'max' not in block['count']:
|
|
||||||
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
|
|
||||||
len(block['locations']) > 0 else len(block['items']))
|
|
||||||
if block['count']['max'] > len(block['items']):
|
|
||||||
count = block['count']
|
|
||||||
failed(f"Plando count {count} greater than items specified", block['force'])
|
|
||||||
block['count'] = len(block['items'])
|
|
||||||
if block['count']['max'] > len(block['locations']) > 0:
|
|
||||||
count = block['count']
|
|
||||||
failed(f"Plando count {count} greater than locations specified", block['force'])
|
|
||||||
block['count'] = len(block['locations'])
|
|
||||||
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
|
|
||||||
|
|
||||||
if block['count']['target'] > 0:
|
for block in removed:
|
||||||
plando_blocks.append(block)
|
multiworld.plando_item_blocks[player].remove(block)
|
||||||
|
|
||||||
|
|
||||||
|
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
|
||||||
|
def warn(warning: str, force: bool | str) -> None:
|
||||||
|
if isinstance(force, bool):
|
||||||
|
logging.warning(f"{warning}")
|
||||||
|
else:
|
||||||
|
logging.debug(f"{warning}")
|
||||||
|
|
||||||
|
def failed(warning: str, force: bool | str) -> None:
|
||||||
|
if force is True:
|
||||||
|
raise Exception(warning)
|
||||||
|
else:
|
||||||
|
warn(warning, force)
|
||||||
|
|
||||||
# shuffle, but then sort blocks by number of locations minus number of items,
|
# shuffle, but then sort blocks by number of locations minus number of items,
|
||||||
# so less-flexible blocks get priority
|
# so less-flexible blocks get priority
|
||||||
multiworld.random.shuffle(plando_blocks)
|
multiworld.random.shuffle(plando_blocks)
|
||||||
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
|
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"]
|
||||||
if len(block['locations']) > 0
|
if len(block.resolved_locations) > 0
|
||||||
else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
|
else len(multiworld.get_unfilled_locations(block.player)) -
|
||||||
|
block.count["target"]))
|
||||||
for placement in plando_blocks:
|
for placement in plando_blocks:
|
||||||
player = placement['player']
|
player = placement.player
|
||||||
try:
|
try:
|
||||||
worlds = placement['world']
|
worlds = placement.worlds
|
||||||
locations = placement['locations']
|
locations = placement.resolved_locations
|
||||||
items = placement['items']
|
items = placement.items
|
||||||
maxcount = placement['count']['target']
|
maxcount = placement.count["target"]
|
||||||
from_pool = placement['from_pool']
|
from_pool = placement.from_pool
|
||||||
|
|
||||||
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
|
item_candidates = []
|
||||||
multiworld.random.shuffle(candidates)
|
if from_pool:
|
||||||
multiworld.random.shuffle(items)
|
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
|
||||||
count = 0
|
for item in multiworld.random.sample(items, maxcount):
|
||||||
err: typing.List[str] = []
|
candidate = next((i for i in instances if i.name == item), None)
|
||||||
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
|
if candidate is None:
|
||||||
claimed_indices: typing.Set[typing.Optional[int]] = set()
|
warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
|
||||||
for item_name in items:
|
f"it's already missing from it", placement.force)
|
||||||
index_to_delete: typing.Optional[int] = None
|
candidate = multiworld.worlds[player].create_item(item)
|
||||||
if from_pool:
|
|
||||||
try:
|
|
||||||
# If from_pool, try to find an existing item with this name & player in the itempool and use it
|
|
||||||
index_to_delete, item = next(
|
|
||||||
(i, item) for i, item in enumerate(multiworld.itempool)
|
|
||||||
if item.player == player and item.name == item_name and i not in claimed_indices
|
|
||||||
)
|
|
||||||
except StopIteration:
|
|
||||||
warn(
|
|
||||||
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
|
|
||||||
placement['force'])
|
|
||||||
item = multiworld.worlds[player].create_item(item_name)
|
|
||||||
else:
|
|
||||||
item = multiworld.worlds[player].create_item(item_name)
|
|
||||||
|
|
||||||
for location in reversed(candidates):
|
|
||||||
if (location.address is None) == (item.code is None): # either both None or both not None
|
|
||||||
if not location.item:
|
|
||||||
if location.item_rule(item):
|
|
||||||
if location.can_fill(multiworld.state, item, False):
|
|
||||||
successful_pairs.append((index_to_delete, item, location))
|
|
||||||
claimed_indices.add(index_to_delete)
|
|
||||||
candidates.remove(location)
|
|
||||||
count = count + 1
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
err.append(f"Can't place item at {location} due to fill condition not met.")
|
|
||||||
else:
|
|
||||||
err.append(f"{item_name} not allowed at {location}.")
|
|
||||||
else:
|
|
||||||
err.append(f"Cannot place {item_name} into already filled location {location}.")
|
|
||||||
else:
|
else:
|
||||||
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
|
multiworld.itempool.remove(candidate)
|
||||||
|
instances.remove(candidate)
|
||||||
if count == maxcount:
|
item_candidates.append(candidate)
|
||||||
break
|
else:
|
||||||
if count < placement['count']['min']:
|
item_candidates = [multiworld.worlds[player].create_item(item)
|
||||||
m = placement['count']['min']
|
for item in multiworld.random.sample(items, maxcount)]
|
||||||
failed(
|
if any(item.code is None for item in item_candidates) \
|
||||||
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
|
and not all(item.code is None for item in item_candidates):
|
||||||
placement['force'])
|
failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
|
||||||
|
f"event items and non-event items. "
|
||||||
# Sort indices in reverse so we can remove them one by one
|
f"Event items: {[item for item in item_candidates if item.code is None]}, "
|
||||||
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
|
f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
|
||||||
|
placement.force)
|
||||||
for (index, item, location) in successful_pairs:
|
continue
|
||||||
multiworld.push_item(location, item, collect=False)
|
else:
|
||||||
location.locked = True
|
is_real = item_candidates[0].code is not None
|
||||||
logging.debug(f"Plando placed {item} at {location}")
|
candidates = [candidate for candidate in locations if candidate.item is None
|
||||||
if index is not None: # If this item is from_pool and was found in the pool, remove it.
|
and bool(candidate.address) == is_real]
|
||||||
multiworld.itempool.pop(index)
|
multiworld.random.shuffle(candidates)
|
||||||
|
allstate = multiworld.get_all_state(False)
|
||||||
|
mincount = placement.count["min"]
|
||||||
|
allowed_margin = len(item_candidates) - mincount
|
||||||
|
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True,
|
||||||
|
allow_partial=True, name="Plando Main Fill")
|
||||||
|
|
||||||
|
if len(item_candidates) > allowed_margin:
|
||||||
|
failed(f"Could not place {len(item_candidates)} "
|
||||||
|
f"of {mincount + allowed_margin} item(s) "
|
||||||
|
f"for {multiworld.player_name[player]}, "
|
||||||
|
f"remaining items: {item_candidates}",
|
||||||
|
placement.force)
|
||||||
|
if from_pool:
|
||||||
|
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
|
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
|
||||||
|
|||||||
127
Generate.py
127
Generate.py
@@ -10,8 +10,8 @@ import sys
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Any, Dict, Tuple, Union
|
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
@@ -54,12 +54,22 @@ def mystery_argparse():
|
|||||||
parser.add_argument("--skip_output", action="store_true",
|
parser.add_argument("--skip_output", action="store_true",
|
||||||
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
|
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
|
||||||
"Intended for debugging and testing purposes.")
|
"Intended for debugging and testing purposes.")
|
||||||
|
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()
|
||||||
|
|
||||||
|
if args.skip_output and args.spoiler_only:
|
||||||
|
parser.error("Cannot mix --skip_output and --spoiler_only")
|
||||||
|
elif args.spoiler == 0 and args.spoiler_only:
|
||||||
|
parser.error("Cannot use --spoiler_only when --spoiler=0. Use --skip_output or set --spoiler to a different value")
|
||||||
|
|
||||||
if not os.path.isabs(args.weights_file_path):
|
if not os.path.isabs(args.weights_file_path):
|
||||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||||
if not os.path.isabs(args.meta_file_path):
|
if not os.path.isabs(args.meta_file_path):
|
||||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||||
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +77,7 @@ def get_seed_name(random_source) -> str:
|
|||||||
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||||
|
|
||||||
|
|
||||||
def main(args=None) -> Tuple[argparse.Namespace, int]:
|
def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||||
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
|
||||||
if __name__ == "__main__" and "worlds" in sys.modules:
|
if __name__ == "__main__" and "worlds" in sys.modules:
|
||||||
raise Exception("Worlds system should not be loaded before logging init.")
|
raise Exception("Worlds system should not be loaded before logging init.")
|
||||||
@@ -85,7 +95,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
logging.info("Race mode enabled. Using non-deterministic random source.")
|
logging.info("Race mode enabled. Using non-deterministic random source.")
|
||||||
random.seed() # reset to time-based random source
|
random.seed() # reset to time-based random source
|
||||||
|
|
||||||
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
weights_cache: dict[str, tuple[Any, ...]] = {}
|
||||||
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
||||||
try:
|
try:
|
||||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||||
@@ -108,6 +118,8 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
raise Exception("Cannot mix --sameoptions with --meta")
|
raise Exception("Cannot mix --sameoptions with --meta")
|
||||||
else:
|
else:
|
||||||
meta_weights = None
|
meta_weights = None
|
||||||
|
|
||||||
|
|
||||||
player_id = 1
|
player_id = 1
|
||||||
player_files = {}
|
player_files = {}
|
||||||
for file in os.scandir(args.player_files_path):
|
for file in os.scandir(args.player_files_path):
|
||||||
@@ -154,20 +166,12 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
f"A mix is also permitted.")
|
f"A mix is also permitted.")
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
args.outputname = seed_name
|
||||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
|
||||||
erargs.seed = seed
|
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
||||||
erargs.plando_options = args.plando
|
args.name = {}
|
||||||
erargs.spoiler = args.spoiler
|
|
||||||
erargs.race = args.race
|
|
||||||
erargs.outputname = seed_name
|
|
||||||
erargs.outputpath = args.outputpath
|
|
||||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
|
||||||
erargs.skip_output = args.skip_output
|
|
||||||
erargs.name = {}
|
|
||||||
erargs.csv_output = args.csv_output
|
|
||||||
|
|
||||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
|
||||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
|
||||||
for fname, yamls in weights_cache.items()}
|
for fname, yamls in weights_cache.items()}
|
||||||
|
|
||||||
@@ -192,30 +196,34 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
for player in range(1, args.multi + 1):
|
for player in range(1, args.multi + 1):
|
||||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
erargs.player_options = {}
|
args.player_options = {}
|
||||||
|
|
||||||
player = 1
|
player = 1
|
||||||
while player <= args.multi:
|
while player <= args.multi:
|
||||||
path = player_path_cache[player]
|
path = player_path_cache[player]
|
||||||
if path:
|
if path:
|
||||||
try:
|
try:
|
||||||
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||||
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
||||||
for settingsObject in settings:
|
for settingsObject in settings:
|
||||||
for k, v in vars(settingsObject).items():
|
for k, v in vars(settingsObject).items():
|
||||||
if v is not None:
|
if v is not None:
|
||||||
try:
|
try:
|
||||||
getattr(erargs, k)[player] = v
|
getattr(args, k)[player] = v
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
setattr(erargs, k, {player: v})
|
setattr(args, k, {player: v})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||||
|
|
||||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
# name was not specified
|
||||||
erargs.name[player] = f"Player{player}"
|
if player not in args.name:
|
||||||
elif player not in erargs.name: # if name was not specified, generate it from filename
|
if path == args.weights_file_path:
|
||||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
# weights file, so we need to make the name unique
|
||||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
args.name[player] = f"Player{player}"
|
||||||
|
else:
|
||||||
|
# use the filename
|
||||||
|
args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||||
|
args.name[player] = handle_name(args.name[player], player, name_counter)
|
||||||
|
|
||||||
player += 1
|
player += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -223,13 +231,13 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
else:
|
else:
|
||||||
raise RuntimeError(f'No weights specified for player {player}')
|
raise RuntimeError(f'No weights specified for player {player}')
|
||||||
|
|
||||||
if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
|
if len(set(name.lower() for name in args.name.values())) != len(args.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}")
|
||||||
|
|
||||||
return erargs, seed
|
return args, seed
|
||||||
|
|
||||||
|
|
||||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
def read_weights_yamls(path) -> tuple[Any, ...]:
|
||||||
try:
|
try:
|
||||||
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
||||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
||||||
@@ -239,7 +247,20 @@ def read_weights_yamls(path) -> Tuple[Any, ...]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Failed to read weights ({path})") from e
|
raise Exception(f"Failed to read weights ({path})") from e
|
||||||
|
|
||||||
return tuple(parse_yamls(yaml))
|
from yaml.error import MarkedYAMLError
|
||||||
|
try:
|
||||||
|
return tuple(parse_yamls(yaml))
|
||||||
|
except MarkedYAMLError as ex:
|
||||||
|
if ex.problem_mark:
|
||||||
|
lines = yaml.splitlines()
|
||||||
|
if ex.context_mark:
|
||||||
|
relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
|
||||||
|
else:
|
||||||
|
relevant_lines = lines[ex.problem_mark.line]
|
||||||
|
error_line = " " * ex.problem_mark.column + "^"
|
||||||
|
raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
|
||||||
|
f"\n{relevant_lines}\n{error_line}")
|
||||||
|
raise ex
|
||||||
|
|
||||||
|
|
||||||
def interpret_on_off(value) -> bool:
|
def interpret_on_off(value) -> bool:
|
||||||
@@ -279,33 +300,35 @@ def get_choice(option, root, value=None) -> Any:
|
|||||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||||
|
|
||||||
|
|
||||||
class SafeDict(dict):
|
class SafeFormatter(string.Formatter):
|
||||||
def __missing__(self, key):
|
def get_value(self, key, args, kwargs):
|
||||||
return '{' + key + '}'
|
if isinstance(key, int):
|
||||||
|
if key < len(args):
|
||||||
|
return args[key]
|
||||||
|
else:
|
||||||
|
return "{" + str(key) + "}"
|
||||||
|
else:
|
||||||
|
return kwargs.get(key, "{" + key + "}")
|
||||||
|
|
||||||
|
|
||||||
def handle_name(name: str, player: int, name_counter: Counter):
|
def handle_name(name: str, player: int, name_counter: Counter):
|
||||||
name_counter[name.lower()] += 1
|
name_counter[name.lower()] += 1
|
||||||
number = name_counter[name.lower()]
|
number = name_counter[name.lower()]
|
||||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
|
|
||||||
NUMBER=(number if number > 1 else ''),
|
new_name = SafeFormatter().vformat(new_name, (), {"number": number,
|
||||||
player=player,
|
"NUMBER": (number if number > 1 else ''),
|
||||||
PLAYER=(player if player > 1 else '')))
|
"player": player,
|
||||||
|
"PLAYER": (player if player > 1 else '')})
|
||||||
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
|
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
|
||||||
# Could cause issues for some clients that cannot handle the additional whitespace.
|
# Could cause issues for some clients that cannot handle the additional whitespace.
|
||||||
new_name = new_name.strip()[:16].strip()
|
new_name = new_name.strip()[:16].strip()
|
||||||
|
|
||||||
if new_name == "Archipelago":
|
if new_name == "Archipelago":
|
||||||
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
||||||
return new_name
|
return new_name
|
||||||
|
|
||||||
|
|
||||||
def roll_percentage(percentage: Union[int, float]) -> bool:
|
|
||||||
"""Roll a percentage chance.
|
|
||||||
percentage is expected to be in range [0, 100]"""
|
|
||||||
return random.random() < (float(percentage) / 100)
|
|
||||||
|
|
||||||
|
|
||||||
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
|
||||||
logging.debug(f'Applying {new_weights}')
|
logging.debug(f'Applying {new_weights}')
|
||||||
cleaned_weights = {}
|
cleaned_weights = {}
|
||||||
@@ -350,7 +373,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
|
|||||||
return weights
|
return weights
|
||||||
|
|
||||||
|
|
||||||
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
|
def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
|
||||||
from worlds import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
if not game:
|
if not game:
|
||||||
@@ -371,7 +394,7 @@ def roll_linked_options(weights: dict) -> dict:
|
|||||||
if "name" not in option_set:
|
if "name" not in option_set:
|
||||||
raise ValueError("One of your linked options does not have a name.")
|
raise ValueError("One of your linked options does not have a name.")
|
||||||
try:
|
try:
|
||||||
if roll_percentage(option_set["percentage"]):
|
if Options.roll_percentage(option_set["percentage"]):
|
||||||
logging.debug(f"Linked option {option_set['name']} triggered.")
|
logging.debug(f"Linked option {option_set['name']} triggered.")
|
||||||
new_options = option_set["options"]
|
new_options = option_set["options"]
|
||||||
for category_name, category_options in new_options.items():
|
for category_name, category_options in new_options.items():
|
||||||
@@ -404,7 +427,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
|
|||||||
trigger_result = get_choice("option_result", option_set)
|
trigger_result = get_choice("option_result", option_set)
|
||||||
result = get_choice(key, currently_targeted_weights)
|
result = get_choice(key, currently_targeted_weights)
|
||||||
currently_targeted_weights[key] = result
|
currently_targeted_weights[key] = result
|
||||||
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
|
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)):
|
||||||
for category_name, category_options in option_set["options"].items():
|
for category_name, category_options in option_set["options"].items():
|
||||||
currently_targeted_weights = weights
|
currently_targeted_weights = weights
|
||||||
if category_name:
|
if category_name:
|
||||||
@@ -435,6 +458,14 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
|||||||
|
|
||||||
|
|
||||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||||
|
"""
|
||||||
|
Roll options from specified weights, usually originating from a .yaml options file.
|
||||||
|
|
||||||
|
Important note:
|
||||||
|
The same weights dict is shared between all slots using the same yaml (e.g. generic weights file for filler slots).
|
||||||
|
This means it should never be modified without making a deepcopy first.
|
||||||
|
"""
|
||||||
|
|
||||||
from worlds import AutoWorldRegister
|
from worlds import AutoWorldRegister
|
||||||
|
|
||||||
if "linked_options" in weights:
|
if "linked_options" in weights:
|
||||||
@@ -500,10 +531,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
valid_keys.add(option_key)
|
valid_keys.add(option_key)
|
||||||
|
|
||||||
# TODO remove plando_items after moving it to the options system
|
|
||||||
valid_keys.add("plando_items")
|
|
||||||
if PlandoOptions.items in plando_options:
|
|
||||||
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
|
||||||
if ret.game == "A Link to the Past":
|
if ret.game == "A Link to the Past":
|
||||||
# TODO there are still more LTTP options not on the options system
|
# TODO there are still more LTTP options not on the options system
|
||||||
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
|
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
|
||||||
|
|||||||
394
Launcher.py
394
Launcher.py
@@ -1,29 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
Archipelago launcher for bundled app.
|
Archipelago Launcher
|
||||||
|
|
||||||
* if run with APBP as argument, launch corresponding client.
|
* If run with a patch file as argument, launch corresponding client with the patch file as an argument.
|
||||||
* if run with executable as argument, run it passing argv[2:] as arguments
|
* If run with component name as argument, run it passing argv[2:] as arguments.
|
||||||
* if run without arguments, open launcher GUI
|
* If run without arguments or unknown arguments, open launcher GUI.
|
||||||
|
|
||||||
Scroll down to components= to add components to the launcher as well as setup.py
|
Additional components can be added to worlds.LauncherComponents.components.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import itertools
|
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
from os.path import isfile
|
from os.path import isfile
|
||||||
from shutil import which
|
from shutil import which
|
||||||
from typing import Callable, Optional, Sequence, Tuple, Union
|
from typing import Any
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import ModuleUpdate
|
import ModuleUpdate
|
||||||
|
|
||||||
ModuleUpdate.update()
|
ModuleUpdate.update()
|
||||||
|
|
||||||
import settings
|
import settings
|
||||||
@@ -41,13 +42,17 @@ def open_host_yaml():
|
|||||||
if is_linux:
|
if is_linux:
|
||||||
exe = which('sensible-editor') or which('gedit') or \
|
exe = which('sensible-editor') or which('gedit') or \
|
||||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||||
subprocess.Popen([exe, file])
|
|
||||||
elif is_macos:
|
elif is_macos:
|
||||||
exe = which("open")
|
exe = which("open")
|
||||||
subprocess.Popen([exe, file])
|
|
||||||
else:
|
else:
|
||||||
webbrowser.open(file)
|
webbrowser.open(file)
|
||||||
|
return
|
||||||
|
|
||||||
|
env = os.environ
|
||||||
|
if "LD_LIBRARY_PATH" in env:
|
||||||
|
env = env.copy()
|
||||||
|
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||||
|
subprocess.Popen([exe, file], env=env)
|
||||||
|
|
||||||
def open_patch():
|
def open_patch():
|
||||||
suffixes = []
|
suffixes = []
|
||||||
@@ -85,12 +90,20 @@ def browse_files():
|
|||||||
def open_folder(folder_path):
|
def open_folder(folder_path):
|
||||||
if is_linux:
|
if is_linux:
|
||||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||||
subprocess.Popen([exe, folder_path])
|
|
||||||
elif is_macos:
|
elif is_macos:
|
||||||
exe = which("open")
|
exe = which("open")
|
||||||
subprocess.Popen([exe, folder_path])
|
|
||||||
else:
|
else:
|
||||||
webbrowser.open(folder_path)
|
webbrowser.open(folder_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
if exe:
|
||||||
|
env = os.environ
|
||||||
|
if "LD_LIBRARY_PATH" in env:
|
||||||
|
env = env.copy()
|
||||||
|
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||||
|
subprocess.Popen([exe, folder_path], env=env)
|
||||||
|
else:
|
||||||
|
logging.warning(f"No file browser available to open {folder_path}")
|
||||||
|
|
||||||
|
|
||||||
def update_settings():
|
def update_settings():
|
||||||
@@ -100,74 +113,51 @@ def update_settings():
|
|||||||
|
|
||||||
components.extend([
|
components.extend([
|
||||||
# Functions
|
# Functions
|
||||||
Component("Open host.yaml", func=open_host_yaml),
|
Component("Open host.yaml", func=open_host_yaml,
|
||||||
Component("Open Patch", func=open_patch),
|
description="Open the host.yaml file to change settings for generation, games, and more."),
|
||||||
Component("Generate Template Options", func=generate_yamls),
|
Component("Open Patch", func=open_patch,
|
||||||
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
|
description="Open a patch file, downloaded from the room page or provided by the host."),
|
||||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
Component("Generate Template Options", func=generate_yamls,
|
||||||
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
description="Generate template YAMLs for currently installed games."),
|
||||||
Component("Browse Files", func=browse_files),
|
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
|
||||||
|
description="Open archipelago.gg in your browser."),
|
||||||
|
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
|
||||||
|
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
|
||||||
|
Component("Unrated/18+ Discord Server", icon="discord",
|
||||||
|
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
|
||||||
|
description="Find unrated and 18+ games in the After Dark Discord server."),
|
||||||
|
Component("Browse Files", func=browse_files,
|
||||||
|
description="Open the Archipelago installation folder in your file browser."),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
|
def handle_uri(path: str) -> tuple[list[Component], Component]:
|
||||||
url = urllib.parse.urlparse(path)
|
url = urllib.parse.urlparse(path)
|
||||||
queries = urllib.parse.parse_qs(url.query)
|
queries = urllib.parse.parse_qs(url.query)
|
||||||
launch_args = (path, *launch_args)
|
client_components = []
|
||||||
client_component = None
|
|
||||||
text_client_component = None
|
text_client_component = None
|
||||||
if "game" in queries:
|
game = queries["game"][0]
|
||||||
game = queries["game"][0]
|
|
||||||
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
|
|
||||||
game = "Archipelago"
|
|
||||||
for component in components:
|
for component in components:
|
||||||
if component.supports_uri and component.game_name == game:
|
if component.supports_uri and component.game_name == game:
|
||||||
client_component = component
|
client_components.append(component)
|
||||||
elif component.display_name == "Text Client":
|
elif component.display_name == "Text Client":
|
||||||
text_client_component = component
|
text_client_component = component
|
||||||
|
return client_components, text_client_component
|
||||||
if client_component is None:
|
|
||||||
run_component(text_client_component, *launch_args)
|
|
||||||
return
|
|
||||||
|
|
||||||
from kvui import App, Button, BoxLayout, Label, Window
|
|
||||||
|
|
||||||
class Popup(App):
|
|
||||||
def __init__(self):
|
|
||||||
self.title = "Connect to Multiworld"
|
|
||||||
self.icon = r"data/icon.png"
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def build(self):
|
|
||||||
layout = BoxLayout(orientation="vertical")
|
|
||||||
layout.add_widget(Label(text="Select client to open and connect with."))
|
|
||||||
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
|
|
||||||
|
|
||||||
text_client_button = Button(
|
|
||||||
text=text_client_component.display_name,
|
|
||||||
on_release=lambda *args: run_component(text_client_component, *launch_args)
|
|
||||||
)
|
|
||||||
button_row.add_widget(text_client_button)
|
|
||||||
|
|
||||||
game_client_button = Button(
|
|
||||||
text=client_component.display_name,
|
|
||||||
on_release=lambda *args: run_component(client_component, *launch_args)
|
|
||||||
)
|
|
||||||
button_row.add_widget(game_client_button)
|
|
||||||
|
|
||||||
layout.add_widget(button_row)
|
|
||||||
|
|
||||||
return layout
|
|
||||||
|
|
||||||
def _stop(self, *largs):
|
|
||||||
# see run_gui Launcher _stop comment for details
|
|
||||||
self.root_window.close()
|
|
||||||
super()._stop(*largs)
|
|
||||||
|
|
||||||
Popup().run()
|
|
||||||
|
|
||||||
|
|
||||||
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
|
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
|
||||||
|
from kvui import ButtonsPrompt
|
||||||
|
component_options = {
|
||||||
|
component.display_name: component for component in component_list
|
||||||
|
}
|
||||||
|
popup = ButtonsPrompt("Connect to Multiworld",
|
||||||
|
"Select client to open and connect with.",
|
||||||
|
lambda component_name: run_component(component_options[component_name], *launch_args),
|
||||||
|
*component_options.keys())
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
|
||||||
|
def identify(path: None | str) -> tuple[None | str, None | Component]:
|
||||||
if path is None:
|
if path is None:
|
||||||
return None, None
|
return None, None
|
||||||
for component in components:
|
for component in components:
|
||||||
@@ -178,7 +168,7 @@ def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Comp
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
def get_exe(component: str | Component) -> Sequence[str] | None:
|
||||||
if isinstance(component, str):
|
if isinstance(component, str):
|
||||||
name = component
|
name = component
|
||||||
component = None
|
component = None
|
||||||
@@ -206,7 +196,8 @@ def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
|||||||
def launch(exe, in_terminal=False):
|
def launch(exe, in_terminal=False):
|
||||||
if in_terminal:
|
if in_terminal:
|
||||||
if is_windows:
|
if is_windows:
|
||||||
subprocess.Popen(['start', *exe], shell=True)
|
# intentionally using a window title with a space so it gets quoted and treated as a title
|
||||||
|
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
|
||||||
return
|
return
|
||||||
elif is_linux:
|
elif is_linux:
|
||||||
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
||||||
@@ -220,100 +211,189 @@ def launch(exe, in_terminal=False):
|
|||||||
subprocess.Popen(exe)
|
subprocess.Popen(exe)
|
||||||
|
|
||||||
|
|
||||||
refresh_components: Optional[Callable[[], None]] = None
|
def create_shortcut(button: Any, component: Component) -> None:
|
||||||
|
from pyshortcuts import make_shortcut
|
||||||
|
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)
|
||||||
|
button.menu.dismiss()
|
||||||
|
|
||||||
|
|
||||||
def run_gui():
|
refresh_components: Callable[[], None] | None = None
|
||||||
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage
|
|
||||||
|
|
||||||
|
def run_gui(launch_components: list[Component], args: Any) -> None:
|
||||||
|
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
|
||||||
|
from kivy.properties import ObjectProperty
|
||||||
from kivy.core.window import Window
|
from kivy.core.window import Window
|
||||||
from kivy.uix.relativelayout import RelativeLayout
|
from kivy.metrics import dp
|
||||||
|
from kivymd.uix.button import MDIconButton, MDButton
|
||||||
|
from kivymd.uix.card import MDCard
|
||||||
|
from kivymd.uix.menu import MDDropdownMenu
|
||||||
|
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
|
||||||
|
from kivymd.uix.textfield import MDTextField
|
||||||
|
|
||||||
class Launcher(App):
|
from kivy.lang.builder import Builder
|
||||||
|
|
||||||
|
class LauncherCard(MDCard):
|
||||||
|
component: Component | None
|
||||||
|
image: str
|
||||||
|
context_button: MDIconButton = ObjectProperty(None)
|
||||||
|
|
||||||
|
def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
|
||||||
|
self.component = component
|
||||||
|
self.image = image_path
|
||||||
|
super().__init__(args, kwargs)
|
||||||
|
|
||||||
|
class Launcher(ThemedApp):
|
||||||
base_title: str = "Archipelago Launcher"
|
base_title: str = "Archipelago Launcher"
|
||||||
container: ContainerLayout
|
top_screen: MDFloatLayout = ObjectProperty(None)
|
||||||
grid: GridLayout
|
navigation: MDGridLayout = ObjectProperty(None)
|
||||||
_tool_layout: Optional[ScrollBox] = None
|
grid: MDGridLayout = ObjectProperty(None)
|
||||||
_client_layout: Optional[ScrollBox] = None
|
button_layout: ScrollBox = ObjectProperty(None)
|
||||||
|
search_box: MDTextField = ObjectProperty(None)
|
||||||
|
cards: list[LauncherCard]
|
||||||
|
current_filter: Sequence[str | Type] | None
|
||||||
|
|
||||||
def __init__(self, ctx=None):
|
def __init__(self, ctx=None, components=None, args=None):
|
||||||
self.title = self.base_title + " " + Utils.__version__
|
self.title = self.base_title + " " + Utils.__version__
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
self.icon = r"data/icon.png"
|
self.icon = r"data/icon.png"
|
||||||
|
self.favorites = []
|
||||||
|
self.launch_components = components
|
||||||
|
self.launch_args = args
|
||||||
|
self.cards = []
|
||||||
|
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
|
||||||
|
persistent = Utils.persistent_load()
|
||||||
|
if "launcher" in persistent:
|
||||||
|
if "favorites" in persistent["launcher"]:
|
||||||
|
self.favorites.extend(persistent["launcher"]["favorites"])
|
||||||
|
if "filter" in persistent["launcher"]:
|
||||||
|
if persistent["launcher"]["filter"]:
|
||||||
|
filters = []
|
||||||
|
for filter in persistent["launcher"]["filter"].split(", "):
|
||||||
|
if filter == "favorites":
|
||||||
|
filters.append(filter)
|
||||||
|
else:
|
||||||
|
filters.append(Type[filter])
|
||||||
|
self.current_filter = filters
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def _refresh_components(self) -> None:
|
def set_favorite(self, caller):
|
||||||
|
if caller.component.display_name in self.favorites:
|
||||||
|
self.favorites.remove(caller.component.display_name)
|
||||||
|
caller.icon = "star-outline"
|
||||||
|
else:
|
||||||
|
self.favorites.append(caller.component.display_name)
|
||||||
|
caller.icon = "star"
|
||||||
|
|
||||||
def build_button(component: Component) -> Widget:
|
def build_card(self, component: Component) -> LauncherCard:
|
||||||
|
"""
|
||||||
|
Builds a card widget for a given component.
|
||||||
|
|
||||||
|
:param component: The component associated with the button.
|
||||||
|
|
||||||
|
:return: The created Card Widget.
|
||||||
"""
|
"""
|
||||||
Builds a button widget for a given component.
|
button_card = LauncherCard(component=component,
|
||||||
|
image_path=icon_paths[component.icon])
|
||||||
|
|
||||||
Args:
|
def open_menu(caller):
|
||||||
component (Component): The component associated with the button.
|
caller.menu.open()
|
||||||
|
|
||||||
Returns:
|
menu_items = [
|
||||||
None. The button is added to the parent grid layout.
|
{
|
||||||
|
"text": "Add shortcut on desktop",
|
||||||
|
"leading_icon": "laptop",
|
||||||
|
"on_release": lambda: create_shortcut(button_card.context_button, component)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items)
|
||||||
|
button_card.context_button.bind(on_release=open_menu)
|
||||||
|
|
||||||
"""
|
return button_card
|
||||||
button = Button(text=component.display_name, size_hint_y=None, height=40)
|
|
||||||
button.component = component
|
def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
|
||||||
button.bind(on_release=self.component_action)
|
if not type_filter:
|
||||||
if component.icon != "icon":
|
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
|
||||||
image = ApAsyncImage(source=icon_paths[component.icon],
|
favorites = "favorites" in type_filter
|
||||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
|
||||||
box_layout = RelativeLayout(size_hint_y=None, height=40)
|
|
||||||
box_layout.add_widget(button)
|
|
||||||
box_layout.add_widget(image)
|
|
||||||
return box_layout
|
|
||||||
return button
|
|
||||||
|
|
||||||
# clear before repopulating
|
# clear before repopulating
|
||||||
assert self._tool_layout and self._client_layout, "must call `build` first"
|
assert self.button_layout, "must call `build` first"
|
||||||
tool_children = reversed(self._tool_layout.layout.children)
|
tool_children = reversed(self.button_layout.layout.children)
|
||||||
for child in tool_children:
|
for child in tool_children:
|
||||||
self._tool_layout.layout.remove_widget(child)
|
self.button_layout.layout.remove_widget(child)
|
||||||
client_children = reversed(self._client_layout.layout.children)
|
|
||||||
for child in client_children:
|
|
||||||
self._client_layout.layout.remove_widget(child)
|
|
||||||
|
|
||||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
cards = [card for card in self.cards if card.component.type in type_filter
|
||||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
or favorites and card.component.display_name in self.favorites]
|
||||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
|
|
||||||
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
|
|
||||||
|
|
||||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
self.current_filter = type_filter
|
||||||
_tools.items(), _miscs.items(), _adjusters.items()
|
|
||||||
), _clients.items()):
|
for card in cards:
|
||||||
# column 1
|
self.button_layout.layout.add_widget(card)
|
||||||
if tool:
|
|
||||||
self._tool_layout.layout.add_widget(build_button(tool[1]))
|
top = self.button_layout.children[0].y + self.button_layout.children[0].height \
|
||||||
# column 2
|
- self.button_layout.height
|
||||||
if client:
|
scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
|
||||||
self._client_layout.layout.add_widget(build_button(client[1]))
|
self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
|
||||||
|
|
||||||
|
def filter_clients_by_type(self, caller: MDButton):
|
||||||
|
self._refresh_components(caller.type)
|
||||||
|
self.search_box.text = ""
|
||||||
|
|
||||||
|
def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
|
||||||
|
if len(name) == 0:
|
||||||
|
self._refresh_components(self.current_filter)
|
||||||
|
return
|
||||||
|
|
||||||
|
sub_matches = [
|
||||||
|
card for card in self.cards
|
||||||
|
if name.lower() in card.component.display_name.lower() and card.component.type != Type.HIDDEN
|
||||||
|
]
|
||||||
|
self.button_layout.layout.clear_widgets()
|
||||||
|
for card in sub_matches:
|
||||||
|
self.button_layout.layout.add_widget(card)
|
||||||
|
|
||||||
def build(self):
|
def build(self):
|
||||||
self.container = ContainerLayout()
|
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
|
||||||
self.grid = GridLayout(cols=2)
|
self.grid = self.top_screen.ids.grid
|
||||||
self.container.add_widget(self.grid)
|
self.navigation = self.top_screen.ids.navigation
|
||||||
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
|
self.button_layout = self.top_screen.ids.button_layout
|
||||||
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
|
self.search_box = self.top_screen.ids.search_box
|
||||||
self._tool_layout = ScrollBox()
|
self.set_colors()
|
||||||
self._tool_layout.layout.orientation = "vertical"
|
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
|
||||||
self.grid.add_widget(self._tool_layout)
|
|
||||||
self._client_layout = ScrollBox()
|
|
||||||
self._client_layout.layout.orientation = "vertical"
|
|
||||||
self.grid.add_widget(self._client_layout)
|
|
||||||
|
|
||||||
self._refresh_components()
|
|
||||||
|
|
||||||
global refresh_components
|
global refresh_components
|
||||||
refresh_components = self._refresh_components
|
refresh_components = self._refresh_components
|
||||||
|
|
||||||
Window.bind(on_drop_file=self._on_drop_file)
|
Window.bind(on_drop_file=self._on_drop_file)
|
||||||
|
Window.bind(on_keyboard=self._on_keyboard)
|
||||||
|
|
||||||
return self.container
|
for component in components:
|
||||||
|
self.cards.append(self.build_card(component))
|
||||||
|
|
||||||
|
self._refresh_components(self.current_filter)
|
||||||
|
|
||||||
|
# Uncomment to re-enable the Kivy console/live editor
|
||||||
|
# Ctrl-E to enable it, make sure numlock/capslock is disabled
|
||||||
|
# from kivy.modules.console import create_console
|
||||||
|
# create_console(Window, self.top_screen)
|
||||||
|
|
||||||
|
return self.top_screen
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
if self.launch_components:
|
||||||
|
build_uri_popup(self.launch_components, self.launch_args)
|
||||||
|
self.launch_components = None
|
||||||
|
self.launch_args = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def component_action(button):
|
def component_action(button):
|
||||||
|
MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
|
||||||
|
size_hint_x=0.5).open()
|
||||||
if button.component.func:
|
if button.component.func:
|
||||||
button.component.func()
|
button.component.func()
|
||||||
else:
|
else:
|
||||||
@@ -325,7 +405,16 @@ def run_gui():
|
|||||||
if file and component:
|
if file and component:
|
||||||
run_component(component, file)
|
run_component(component, file)
|
||||||
else:
|
else:
|
||||||
logging.warning(f"unable to identify component for {file}")
|
logging.warning(f"unable to identify component for {filename}")
|
||||||
|
|
||||||
|
def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
|
||||||
|
# Activate search as soon as we start typing, no matter if we are focused on the search box or not.
|
||||||
|
# Focus first, then capture the first character we type, otherwise it gets swallowed and lost.
|
||||||
|
# Limit text input to ASCII non-control characters (space bar to tilde).
|
||||||
|
if not self.search_box.focus:
|
||||||
|
self.search_box.focus = True
|
||||||
|
if key in range(32, 126):
|
||||||
|
self.search_box.text += codepoint
|
||||||
|
|
||||||
def _stop(self, *largs):
|
def _stop(self, *largs):
|
||||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||||
@@ -333,7 +422,13 @@ def run_gui():
|
|||||||
self.root_window.close()
|
self.root_window.close()
|
||||||
super()._stop(*largs)
|
super()._stop(*largs)
|
||||||
|
|
||||||
Launcher().run()
|
def on_stop(self):
|
||||||
|
Utils.persistent_store("launcher", "favorites", self.favorites)
|
||||||
|
Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
|
||||||
|
for filter in self.current_filter))
|
||||||
|
super().on_stop()
|
||||||
|
|
||||||
|
Launcher(components=launch_components, args=args).run()
|
||||||
|
|
||||||
# avoiding Launcher reference leak
|
# avoiding Launcher reference leak
|
||||||
# and don't try to do something with widgets after window closed
|
# and don't try to do something with widgets after window closed
|
||||||
@@ -352,7 +447,7 @@ def run_component(component: Component, *args):
|
|||||||
logging.warning(f"Component {component} does not appear to be executable.")
|
logging.warning(f"Component {component} does not appear to be executable.")
|
||||||
|
|
||||||
|
|
||||||
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
def main(args: argparse.Namespace | dict | None = None):
|
||||||
if isinstance(args, argparse.Namespace):
|
if isinstance(args, argparse.Namespace):
|
||||||
args = {k: v for k, v in args._get_kwargs()}
|
args = {k: v for k, v in args._get_kwargs()}
|
||||||
elif not args:
|
elif not args:
|
||||||
@@ -361,15 +456,21 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
|||||||
path = args.get("Patch|Game|Component|url", None)
|
path = args.get("Patch|Game|Component|url", None)
|
||||||
if path is not None:
|
if path is not None:
|
||||||
if path.startswith("archipelago://"):
|
if path.startswith("archipelago://"):
|
||||||
handle_uri(path, args.get("args", ()))
|
args["args"] = (path, *args.get("args", ()))
|
||||||
return
|
# add the url arg to the passthrough args
|
||||||
file, component = identify(path)
|
components, text_client_component = handle_uri(path)
|
||||||
if file:
|
if not components:
|
||||||
args['file'] = file
|
args["component"] = text_client_component
|
||||||
if component:
|
else:
|
||||||
args['component'] = component
|
args['launch_components'] = [text_client_component, *components]
|
||||||
if not component:
|
else:
|
||||||
logging.warning(f"Could not identify Component responsible for {path}")
|
file, component = identify(path)
|
||||||
|
if file:
|
||||||
|
args['file'] = file
|
||||||
|
if component:
|
||||||
|
args['component'] = component
|
||||||
|
if not component:
|
||||||
|
logging.warning(f"Could not identify Component responsible for {path}")
|
||||||
|
|
||||||
if args["update_settings"]:
|
if args["update_settings"]:
|
||||||
update_settings()
|
update_settings()
|
||||||
@@ -378,12 +479,12 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
|||||||
elif "component" in args:
|
elif "component" in args:
|
||||||
run_component(args["component"], *args["args"])
|
run_component(args["component"], *args["args"])
|
||||||
elif not args["update_settings"]:
|
elif not args["update_settings"]:
|
||||||
run_gui()
|
run_gui(args.get("launch_components", None), args.get("args", ()))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
init_logging('Launcher')
|
init_logging('Launcher')
|
||||||
Utils.freeze_support()
|
multiprocessing.freeze_support()
|
||||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description='Archipelago Launcher',
|
description='Archipelago Launcher',
|
||||||
@@ -400,6 +501,7 @@ if __name__ == '__main__':
|
|||||||
main(parser.parse_args())
|
main(parser.parse_args())
|
||||||
|
|
||||||
from worlds.LauncherComponents import processes
|
from worlds.LauncherComponents import processes
|
||||||
|
|
||||||
for process in processes:
|
for process in processes:
|
||||||
# we await all child processes to close before we tear down the process host
|
# we await all child processes to close before we tear down the process host
|
||||||
# this makes it feel like each one is its own program, as the Launcher is closed now
|
# this makes it feel like each one is its own program, as the Launcher is closed now
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ import typing
|
|||||||
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
||||||
server_loop)
|
server_loop)
|
||||||
from NetUtils import ClientStatus
|
from NetUtils import ClientStatus
|
||||||
|
from worlds.ladx import LinksAwakeningWorld
|
||||||
from worlds.ladx.Common import BASE_ID as LABaseID
|
from worlds.ladx.Common import BASE_ID as LABaseID
|
||||||
from worlds.ladx.GpsTracker import GpsTracker
|
from worlds.ladx.GpsTracker import GpsTracker
|
||||||
from worlds.ladx.TrackerConsts import storage_key
|
from worlds.ladx.TrackerConsts import storage_key
|
||||||
from worlds.ladx.ItemTracker import ItemTracker
|
from worlds.ladx.ItemTracker import ItemTracker
|
||||||
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
||||||
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
||||||
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
|
from worlds.ladx.Tracker import LocationTracker, MagpieBridge, Check
|
||||||
|
|
||||||
|
|
||||||
class GameboyException(Exception):
|
class GameboyException(Exception):
|
||||||
@@ -51,22 +52,6 @@ class BadRetroArchResponse(GameboyException):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def magpie_logo():
|
|
||||||
from kivy.uix.image import CoreImage
|
|
||||||
binary_data = """
|
|
||||||
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
|
|
||||||
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
|
|
||||||
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
|
|
||||||
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
|
|
||||||
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
|
|
||||||
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
|
|
||||||
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
|
|
||||||
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
|
|
||||||
binary_data = base64.b64decode(binary_data)
|
|
||||||
data = io.BytesIO(binary_data)
|
|
||||||
return CoreImage(data, ext="png").texture
|
|
||||||
|
|
||||||
|
|
||||||
class LAClientConstants:
|
class LAClientConstants:
|
||||||
# Connector version
|
# Connector version
|
||||||
VERSION = 0x01
|
VERSION = 0x01
|
||||||
@@ -139,7 +124,7 @@ class RAGameboy():
|
|||||||
def set_checks_range(self, checks_start, checks_size):
|
def set_checks_range(self, checks_start, checks_size):
|
||||||
self.checks_start = checks_start
|
self.checks_start = checks_start
|
||||||
self.checks_size = checks_size
|
self.checks_size = checks_size
|
||||||
|
|
||||||
def set_location_range(self, location_start, location_size, critical_addresses):
|
def set_location_range(self, location_start, location_size, critical_addresses):
|
||||||
self.location_start = location_start
|
self.location_start = location_start
|
||||||
self.location_size = location_size
|
self.location_size = location_size
|
||||||
@@ -237,7 +222,7 @@ class RAGameboy():
|
|||||||
self.cache[start:start + len(hram_block)] = hram_block
|
self.cache[start:start + len(hram_block)] = hram_block
|
||||||
|
|
||||||
self.last_cache_read = time.time()
|
self.last_cache_read = time.time()
|
||||||
|
|
||||||
async def read_memory_block(self, address: int, size: int):
|
async def read_memory_block(self, address: int, size: int):
|
||||||
block = bytearray()
|
block = bytearray()
|
||||||
remaining_size = size
|
remaining_size = size
|
||||||
@@ -245,7 +230,7 @@ class RAGameboy():
|
|||||||
chunk = await self.async_read_memory(address + len(block), remaining_size)
|
chunk = await self.async_read_memory(address + len(block), remaining_size)
|
||||||
remaining_size -= len(chunk)
|
remaining_size -= len(chunk)
|
||||||
block += chunk
|
block += chunk
|
||||||
|
|
||||||
return block
|
return block
|
||||||
|
|
||||||
async def read_memory_cache(self, addresses):
|
async def read_memory_cache(self, addresses):
|
||||||
@@ -514,8 +499,8 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
magpie_task = None
|
magpie_task = None
|
||||||
won = False
|
won = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def slot_storage_key(self):
|
def slot_storage_key(self):
|
||||||
return f"{self.slot_info[self.slot].name}_{storage_key}"
|
return f"{self.slot_info[self.slot].name}_{storage_key}"
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||||
@@ -529,9 +514,9 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
|
|
||||||
def run_gui(self) -> None:
|
def run_gui(self) -> None:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
import kvui
|
from kvui import GameManager
|
||||||
from kvui import Button, GameManager
|
from kivy.metrics import dp
|
||||||
from kivy.uix.image import Image
|
from kivymd.uix.button import MDButton, MDButtonText
|
||||||
|
|
||||||
class LADXManager(GameManager):
|
class LADXManager(GameManager):
|
||||||
logging_pairs = [
|
logging_pairs = [
|
||||||
@@ -544,21 +529,17 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
b = super().build()
|
b = super().build()
|
||||||
|
|
||||||
if self.ctx.magpie_enabled:
|
if self.ctx.magpie_enabled:
|
||||||
button = Button(text="", size=(30, 30), size_hint_x=None,
|
button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5,
|
||||||
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.55},
|
||||||
image = Image(size=(16, 16), texture=magpie_logo())
|
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
||||||
button.add_widget(image)
|
button.height = self.server_connect_bar.height
|
||||||
|
|
||||||
def set_center(_, center):
|
|
||||||
image.center = center
|
|
||||||
button.bind(center=set_center)
|
|
||||||
|
|
||||||
self.connect_layout.add_widget(button)
|
self.connect_layout.add_widget(button)
|
||||||
|
|
||||||
return b
|
return b
|
||||||
|
|
||||||
self.ui = LADXManager(self)
|
self.ui = LADXManager(self)
|
||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
|
||||||
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
|
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
|
||||||
# Store the entrances we find on the server for future sessions
|
# Store the entrances we find on the server for future sessions
|
||||||
message = [{
|
message = [{
|
||||||
@@ -597,12 +578,12 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
logger.info("victory!")
|
logger.info("victory!")
|
||||||
await self.send_msgs(message)
|
await self.send_msgs(message)
|
||||||
self.won = True
|
self.won = True
|
||||||
|
|
||||||
async def request_found_entrances(self):
|
async def request_found_entrances(self):
|
||||||
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
|
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
|
||||||
|
|
||||||
# Ask for updates so that players can co-op entrances in a seed
|
# Ask for updates so that players can co-op entrances in a seed
|
||||||
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
|
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
|
||||||
|
|
||||||
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||||
if self.ENABLE_DEATHLINK:
|
if self.ENABLE_DEATHLINK:
|
||||||
@@ -638,12 +619,23 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
if cmd == "Connected":
|
if cmd == "Connected":
|
||||||
self.game = self.slot_info[self.slot].game
|
self.game = self.slot_info[self.slot].game
|
||||||
self.slot_data = args.get("slot_data", {})
|
self.slot_data = args.get("slot_data", {})
|
||||||
|
# This is sent to magpie over local websocket to make its own connection
|
||||||
|
self.slot_data.update({
|
||||||
|
"server_address": self.server_address,
|
||||||
|
"slot_name": self.player_names[self.slot],
|
||||||
|
"password": self.password,
|
||||||
|
})
|
||||||
|
|
||||||
|
# We can process linked items on already-checked checks now that we have slot_data
|
||||||
|
if self.client.tracker:
|
||||||
|
checked_checks = set(self.client.tracker.all_checks) - set(self.client.tracker.remaining_checks)
|
||||||
|
self.add_linked_items(checked_checks)
|
||||||
|
|
||||||
# TODO - use watcher_event
|
# TODO - use watcher_event
|
||||||
if cmd == "ReceivedItems":
|
if cmd == "ReceivedItems":
|
||||||
for index, item in enumerate(args["items"], start=args["index"]):
|
for index, item in enumerate(args["items"], start=args["index"]):
|
||||||
self.client.recvd_checks[index] = item
|
self.client.recvd_checks[index] = item
|
||||||
|
|
||||||
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
|
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
|
||||||
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
|
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
|
||||||
|
|
||||||
@@ -654,6 +646,13 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
sync_msg = [{'cmd': 'Sync'}]
|
sync_msg = [{'cmd': 'Sync'}]
|
||||||
await self.send_msgs(sync_msg)
|
await self.send_msgs(sync_msg)
|
||||||
|
|
||||||
|
def add_linked_items(self, checks: typing.List[Check]):
|
||||||
|
for check in checks:
|
||||||
|
if check.value and check.linkedItem:
|
||||||
|
linkedItem = check.linkedItem
|
||||||
|
if 'condition' not in linkedItem or (self.slot_data and linkedItem['condition'](self.slot_data)):
|
||||||
|
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
|
||||||
|
|
||||||
item_id_lookup = get_locations_to_id()
|
item_id_lookup = get_locations_to_id()
|
||||||
|
|
||||||
async def run_game_loop(self):
|
async def run_game_loop(self):
|
||||||
@@ -662,11 +661,7 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
checkMetadataTable[check.id])] for check in ladxr_checks]
|
checkMetadataTable[check.id])] for check in ladxr_checks]
|
||||||
self.new_checks(checks, [check.id for check in ladxr_checks])
|
self.new_checks(checks, [check.id for check in ladxr_checks])
|
||||||
|
|
||||||
for check in ladxr_checks:
|
self.add_linked_items(ladxr_checks)
|
||||||
if check.value and check.linkedItem:
|
|
||||||
linkedItem = check.linkedItem
|
|
||||||
if 'condition' not in linkedItem or linkedItem['condition'](self.slot_data):
|
|
||||||
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
|
|
||||||
|
|
||||||
async def victory():
|
async def victory():
|
||||||
await self.send_victory()
|
await self.send_victory()
|
||||||
@@ -722,8 +717,10 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
try:
|
try:
|
||||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
await self.magpie.set_item_tracker(self.client.item_tracker)
|
||||||
self.magpie.slot_data = self.slot_data
|
if self.slot_data and "slot_data" in self.magpie.features and not self.magpie.has_sent_slot_data:
|
||||||
|
self.magpie.slot_data = self.slot_data
|
||||||
|
await self.magpie.send_slot_data()
|
||||||
|
|
||||||
if self.client.gps_tracker.needs_found_entrances:
|
if self.client.gps_tracker.needs_found_entrances:
|
||||||
await self.request_found_entrances()
|
await self.request_found_entrances()
|
||||||
self.client.gps_tracker.needs_found_entrances = False
|
self.client.gps_tracker.needs_found_entrances = False
|
||||||
@@ -741,8 +738,8 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
def run_game(romfile: str) -> None:
|
def run_game(romfile: str) -> None:
|
||||||
auto_start = typing.cast(typing.Union[bool, str],
|
auto_start = LinksAwakeningWorld.settings.rom_start
|
||||||
Utils.get_options()["ladx_options"].get("rom_start", True))
|
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ GAME_ALTTP = "A Link to the Past"
|
|||||||
WINDOW_MIN_HEIGHT = 525
|
WINDOW_MIN_HEIGHT = 525
|
||||||
WINDOW_MIN_WIDTH = 425
|
WINDOW_MIN_WIDTH = 425
|
||||||
|
|
||||||
|
|
||||||
class AdjusterWorld(object):
|
class AdjusterWorld(object):
|
||||||
class AdjusterSubWorld(object):
|
class AdjusterSubWorld(object):
|
||||||
def __init__(self, random):
|
def __init__(self, random):
|
||||||
@@ -40,7 +41,6 @@ class AdjusterWorld(object):
|
|||||||
def __init__(self, sprite_pool):
|
def __init__(self, sprite_pool):
|
||||||
import random
|
import random
|
||||||
self.sprite_pool = {1: sprite_pool}
|
self.sprite_pool = {1: sprite_pool}
|
||||||
self.per_slot_randoms = {1: random}
|
|
||||||
self.worlds = {1: self.AdjusterSubWorld(random)}
|
self.worlds = {1: self.AdjusterSubWorld(random)}
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
|||||||
def _get_help_string(self, action):
|
def _get_help_string(self, action):
|
||||||
return textwrap.dedent(action.help)
|
return textwrap.dedent(action.help)
|
||||||
|
|
||||||
|
|
||||||
# See argparse.BooleanOptionalAction
|
# See argparse.BooleanOptionalAction
|
||||||
class BooleanOptionalActionWithDisable(argparse.Action):
|
class BooleanOptionalActionWithDisable(argparse.Action):
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
@@ -364,10 +365,10 @@ def run_sprite_update():
|
|||||||
logging.info("Done updating sprites")
|
logging.info("Done updating sprites")
|
||||||
|
|
||||||
|
|
||||||
def update_sprites(task, on_finish=None):
|
def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"):
|
||||||
resultmessage = ""
|
resultmessage = ""
|
||||||
successful = True
|
successful = True
|
||||||
sprite_dir = user_path("data", "sprites", "alttpr")
|
sprite_dir = user_path("data", "sprites", "alttp", "remote")
|
||||||
os.makedirs(sprite_dir, exist_ok=True)
|
os.makedirs(sprite_dir, exist_ok=True)
|
||||||
ctx = get_cert_none_ssl_context()
|
ctx = get_cert_none_ssl_context()
|
||||||
|
|
||||||
@@ -377,11 +378,11 @@ def update_sprites(task, on_finish=None):
|
|||||||
on_finish(successful, resultmessage)
|
on_finish(successful, resultmessage)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
task.update_status("Downloading alttpr sprites list")
|
task.update_status("Downloading remote sprites list")
|
||||||
with urlopen('https://alttpr.com/sprites', context=ctx) as response:
|
with urlopen(repository_url, context=ctx) as response:
|
||||||
sprites_arr = json.loads(response.read().decode("utf-8"))
|
sprites_arr = json.loads(response.read().decode("utf-8"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
||||||
successful = False
|
successful = False
|
||||||
task.queue_event(finished)
|
task.queue_event(finished)
|
||||||
return
|
return
|
||||||
@@ -389,13 +390,13 @@ def update_sprites(task, on_finish=None):
|
|||||||
try:
|
try:
|
||||||
task.update_status("Determining needed sprites")
|
task.update_status("Determining needed sprites")
|
||||||
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
|
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
|
||||||
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
||||||
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
|
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
|
||||||
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
|
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if
|
||||||
filename not in current_sprites]
|
filename not in current_sprites]
|
||||||
|
|
||||||
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
|
remote_filenames = [filename for (_, filename) in remote_sprites]
|
||||||
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
|
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
|
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
|
||||||
type(e).__name__, e)
|
type(e).__name__, e)
|
||||||
@@ -447,7 +448,7 @@ def update_sprites(task, on_finish=None):
|
|||||||
successful = False
|
successful = False
|
||||||
|
|
||||||
if successful:
|
if successful:
|
||||||
resultmessage = "alttpr sprites updated successfully"
|
resultmessage = "Remote sprites updated successfully"
|
||||||
|
|
||||||
task.queue_event(finished)
|
task.queue_event(finished)
|
||||||
|
|
||||||
@@ -868,7 +869,7 @@ class SpriteSelector():
|
|||||||
def open_custom_sprite_dir(_evt):
|
def open_custom_sprite_dir(_evt):
|
||||||
open_file(self.custom_sprite_dir)
|
open_file(self.custom_sprite_dir)
|
||||||
|
|
||||||
alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
|
remote_frametitle = Label(self.window, text='Remote Sprites')
|
||||||
|
|
||||||
custom_frametitle = Frame(self.window)
|
custom_frametitle = Frame(self.window)
|
||||||
title_text = Label(custom_frametitle, text="Custom Sprites")
|
title_text = Label(custom_frametitle, text="Custom Sprites")
|
||||||
@@ -877,8 +878,8 @@ class SpriteSelector():
|
|||||||
title_link.pack(side=LEFT)
|
title_link.pack(side=LEFT)
|
||||||
title_link.bind("<Button-1>", open_custom_sprite_dir)
|
title_link.bind("<Button-1>", open_custom_sprite_dir)
|
||||||
|
|
||||||
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
|
self.icon_section(remote_frametitle, self.remote_sprite_dir,
|
||||||
'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
|
'Remote sprites not found. Click "Update remote sprites" to download them.')
|
||||||
self.icon_section(custom_frametitle, self.custom_sprite_dir,
|
self.icon_section(custom_frametitle, self.custom_sprite_dir,
|
||||||
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
|
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
|
||||||
if not randomOnEvent:
|
if not randomOnEvent:
|
||||||
@@ -891,11 +892,18 @@ class SpriteSelector():
|
|||||||
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
|
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
|
||||||
button.pack(side=RIGHT, padx=(5, 0))
|
button.pack(side=RIGHT, padx=(5, 0))
|
||||||
|
|
||||||
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
|
button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites)
|
||||||
button.pack(side=RIGHT, padx=(5, 0))
|
button.pack(side=RIGHT, padx=(5, 0))
|
||||||
|
|
||||||
|
repository_label = Label(frame, text='Sprite Repository:')
|
||||||
|
self.repository_url = StringVar(frame, "https://alttpr.com/sprites")
|
||||||
|
repository_entry = Entry(frame, textvariable=self.repository_url)
|
||||||
|
|
||||||
|
repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1)
|
||||||
|
repository_label.pack(side=RIGHT, expand=False, padx=(0, 5))
|
||||||
|
|
||||||
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
|
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
|
||||||
button.pack(side=LEFT,padx=(0,5))
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
|
|
||||||
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
|
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
|
||||||
button.pack(side=LEFT, padx=(0, 5))
|
button.pack(side=LEFT, padx=(0, 5))
|
||||||
@@ -1055,7 +1063,7 @@ class SpriteSelector():
|
|||||||
for i, button in enumerate(frame.buttons):
|
for i, button in enumerate(frame.buttons):
|
||||||
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
|
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
|
||||||
|
|
||||||
def update_alttpr_sprites(self):
|
def update_remote_sprites(self):
|
||||||
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
|
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
|
||||||
self.window.destroy()
|
self.window.destroy()
|
||||||
self.parent.update()
|
self.parent.update()
|
||||||
@@ -1068,7 +1076,8 @@ class SpriteSelector():
|
|||||||
messagebox.showerror("Sprite Updater", resultmessage)
|
messagebox.showerror("Sprite Updater", resultmessage)
|
||||||
SpriteSelector(self.parent, self.callback, self.adjuster)
|
SpriteSelector(self.parent, self.callback, self.adjuster)
|
||||||
|
|
||||||
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
|
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites",
|
||||||
|
on_finish, self.repository_url.get())
|
||||||
|
|
||||||
def browse_for_sprite(self):
|
def browse_for_sprite(self):
|
||||||
sprite = filedialog.askopenfilename(
|
sprite = filedialog.askopenfilename(
|
||||||
@@ -1158,12 +1167,13 @@ class SpriteSelector():
|
|||||||
os.makedirs(self.custom_sprite_dir)
|
os.makedirs(self.custom_sprite_dir)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def alttpr_sprite_dir(self):
|
def remote_sprite_dir(self):
|
||||||
return user_path("data", "sprites", "alttpr")
|
return user_path("data", "sprites", "alttp", "remote")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def custom_sprite_dir(self):
|
def custom_sprite_dir(self):
|
||||||
return user_path("data", "sprites", "custom")
|
return user_path("data", "sprites", "alttp", "custom")
|
||||||
|
|
||||||
|
|
||||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||||
if not sprite.valid:
|
if not sprite.valid:
|
||||||
|
|||||||
@@ -286,16 +286,14 @@ async def gba_sync_task(ctx: MMBN3Context):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
options = Utils.get_options().get("mmbn3_options", None)
|
from worlds.mmbn3 import MMBN3World
|
||||||
if options is None:
|
auto_start = MMBN3World.settings.rom_start
|
||||||
auto_start = True
|
if auto_start is True:
|
||||||
else:
|
|
||||||
auto_start = options.get("rom_start", True)
|
|
||||||
if auto_start:
|
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
elif os.path.isfile(auto_start):
|
elif os.path.isfile(auto_start):
|
||||||
|
|||||||
120
Main.py
120
Main.py
@@ -1,20 +1,21 @@
|
|||||||
import collections
|
import collections
|
||||||
|
from collections.abc import Mapping
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pickle
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
from typing import Any
|
||||||
import zipfile
|
import zipfile
|
||||||
import zlib
|
import zlib
|
||||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
|
||||||
|
|
||||||
import worlds
|
import worlds
|
||||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
||||||
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
|
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
|
||||||
flood_items
|
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
|
||||||
|
from NetUtils import convert_to_base_types
|
||||||
from Options import StartInventoryPool
|
from Options import StartInventoryPool
|
||||||
from Utils import __version__, output_path, version_tuple, get_settings
|
from Utils import __version__, output_path, restricted_dumps, version_tuple
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
from worlds import AutoWorld
|
from worlds import AutoWorld
|
||||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||||
@@ -22,7 +23,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
|
|||||||
__all__ = ["main"]
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
|
def main(args, seed=None, baked_server_options: dict[str, object] | None = None):
|
||||||
if not baked_server_options:
|
if not baked_server_options:
|
||||||
baked_server_options = get_settings().server_options.as_dict()
|
baked_server_options = get_settings().server_options.as_dict()
|
||||||
assert isinstance(baked_server_options, dict)
|
assert isinstance(baked_server_options, dict)
|
||||||
@@ -36,10 +37,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
||||||
multiworld.plando_options = args.plando_options
|
multiworld.plando_options = args.plando
|
||||||
multiworld.plando_items = args.plando_items.copy()
|
|
||||||
multiworld.plando_texts = args.plando_texts.copy()
|
|
||||||
multiworld.plando_connections = args.plando_connections.copy()
|
|
||||||
multiworld.game = args.game.copy()
|
multiworld.game = args.game.copy()
|
||||||
multiworld.player_name = args.name.copy()
|
multiworld.player_name = args.name.copy()
|
||||||
multiworld.sprite = args.sprite.copy()
|
multiworld.sprite = args.sprite.copy()
|
||||||
@@ -56,32 +54,18 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
||||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||||
|
|
||||||
max_item = 0
|
|
||||||
max_location = 0
|
|
||||||
for cls in AutoWorld.AutoWorldRegister.world_types.values():
|
|
||||||
if cls.item_id_to_name:
|
|
||||||
max_item = max(max_item, max(cls.item_id_to_name))
|
|
||||||
max_location = max(max_location, max(cls.location_id_to_name))
|
|
||||||
|
|
||||||
item_digits = len(str(max_item))
|
|
||||||
location_digits = len(str(max_location))
|
|
||||||
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
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())))
|
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
|
||||||
del max_item, max_location
|
|
||||||
|
|
||||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||||
if not cls.hidden and len(cls.item_names) > 0:
|
if not cls.hidden and len(cls.item_names) > 0:
|
||||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
|
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
|
||||||
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
|
f"Locations: {len(cls.location_names):{location_count}}")
|
||||||
f"{max(cls.item_id_to_name):{item_digits}}) | "
|
|
||||||
f"{len(cls.location_names):{location_count}} "
|
|
||||||
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
|
|
||||||
f"{max(cls.location_id_to_name):{location_digits}})")
|
|
||||||
|
|
||||||
del item_digits, location_digits, item_count, location_count
|
del item_count, location_count
|
||||||
|
|
||||||
# This assertion method should not be necessary to run if we are not outputting any multidata.
|
# This assertion method should not be necessary to run if we are not outputting any multidata.
|
||||||
if not args.skip_output:
|
if not args.skip_output and not args.spoiler_only:
|
||||||
AutoWorld.call_stage(multiworld, "assert_generate")
|
AutoWorld.call_stage(multiworld, "assert_generate")
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "generate_early")
|
AutoWorld.call_all(multiworld, "generate_early")
|
||||||
@@ -110,6 +94,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
del local_early
|
del local_early
|
||||||
del early
|
del early
|
||||||
|
|
||||||
|
# items can't be both local and non-local, prefer local
|
||||||
|
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
|
||||||
|
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
|
||||||
|
|
||||||
|
# Clear non-applicable local and non-local items.
|
||||||
|
if multiworld.players == 1:
|
||||||
|
multiworld.worlds[1].options.non_local_items.value = set()
|
||||||
|
multiworld.worlds[1].options.local_items.value = set()
|
||||||
|
|
||||||
logger.info('Creating MultiWorld.')
|
logger.info('Creating MultiWorld.')
|
||||||
AutoWorld.call_all(multiworld, "create_regions")
|
AutoWorld.call_all(multiworld, "create_regions")
|
||||||
|
|
||||||
@@ -117,12 +110,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
AutoWorld.call_all(multiworld, "create_items")
|
AutoWorld.call_all(multiworld, "create_items")
|
||||||
|
|
||||||
logger.info('Calculating Access Rules.')
|
logger.info('Calculating Access Rules.')
|
||||||
|
|
||||||
for player in multiworld.player_ids:
|
|
||||||
# items can't be both local and non-local, prefer local
|
|
||||||
multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
|
|
||||||
multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
|
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "set_rules")
|
AutoWorld.call_all(multiworld, "set_rules")
|
||||||
|
|
||||||
for player in multiworld.player_ids:
|
for player in multiworld.player_ids:
|
||||||
@@ -143,11 +130,11 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
|
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
|
||||||
|
|
||||||
# Set local and non-local item rules.
|
# Set local and non-local item rules.
|
||||||
|
# This function is called so late because worlds might otherwise overwrite item_rules which are how locality works
|
||||||
if multiworld.players > 1:
|
if multiworld.players > 1:
|
||||||
locality_rules(multiworld)
|
locality_rules(multiworld)
|
||||||
else:
|
|
||||||
multiworld.worlds[1].options.non_local_items.value = set()
|
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
|
||||||
multiworld.worlds[1].options.local_items.value = set()
|
|
||||||
|
|
||||||
AutoWorld.call_all(multiworld, "connect_entrances")
|
AutoWorld.call_all(multiworld, "connect_entrances")
|
||||||
AutoWorld.call_all(multiworld, "generate_basic")
|
AutoWorld.call_all(multiworld, "generate_basic")
|
||||||
@@ -155,7 +142,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
# remove starting inventory from pool items.
|
# remove starting inventory from pool items.
|
||||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||||
fallback_inventory = StartInventoryPool({})
|
fallback_inventory = StartInventoryPool({})
|
||||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
depletion_pool: dict[int, dict[str, int]] = {
|
||||||
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
|
||||||
for player in multiworld.player_ids
|
for player in multiworld.player_ids
|
||||||
}
|
}
|
||||||
@@ -164,7 +151,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
}
|
}
|
||||||
|
|
||||||
if target_per_player:
|
if target_per_player:
|
||||||
new_itempool: List[Item] = []
|
new_itempool: list[Item] = []
|
||||||
|
|
||||||
# Make new itempool with start_inventory_from_pool items removed
|
# Make new itempool with start_inventory_from_pool items removed
|
||||||
for item in multiworld.itempool:
|
for item in multiworld.itempool:
|
||||||
@@ -189,12 +176,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
|
|
||||||
multiworld.link_items()
|
multiworld.link_items()
|
||||||
|
|
||||||
if any(multiworld.item_links.values()):
|
if any(world.options.item_links for world in multiworld.worlds.values()):
|
||||||
multiworld._all_state = None
|
multiworld._all_state = None
|
||||||
|
|
||||||
logger.info("Running Item Plando.")
|
logger.info("Running Item Plando.")
|
||||||
|
resolve_early_locations_for_planned(multiworld)
|
||||||
distribute_planned(multiworld)
|
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks
|
||||||
|
for x in multiworld.plando_item_blocks[player]])
|
||||||
|
|
||||||
logger.info('Running Pre Main Fill.')
|
logger.info('Running Pre Main Fill.')
|
||||||
|
|
||||||
@@ -224,6 +212,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
logger.info(f'Beginning output...')
|
logger.info(f'Beginning output...')
|
||||||
outfilebase = 'AP_' + multiworld.seed_name
|
outfilebase = 'AP_' + multiworld.seed_name
|
||||||
|
|
||||||
|
if args.spoiler_only:
|
||||||
|
if args.spoiler > 1:
|
||||||
|
logger.info('Calculating playthrough.')
|
||||||
|
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
||||||
|
|
||||||
|
multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
|
||||||
|
logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start)
|
||||||
|
return multiworld
|
||||||
|
|
||||||
output = tempfile.TemporaryDirectory()
|
output = tempfile.TemporaryDirectory()
|
||||||
with output as temp_dir:
|
with output as temp_dir:
|
||||||
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
|
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
|
||||||
@@ -238,17 +235,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
|
||||||
|
|
||||||
# collect ER hint info
|
# collect ER hint info
|
||||||
er_hint_data: Dict[int, Dict[int, str]] = {}
|
er_hint_data: dict[int, dict[int, str]] = {}
|
||||||
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
|
||||||
|
|
||||||
def write_multidata():
|
def write_multidata():
|
||||||
import NetUtils
|
import NetUtils
|
||||||
from NetUtils import HintStatus
|
from NetUtils import HintStatus
|
||||||
slot_data = {}
|
slot_data: dict[int, Mapping[str, Any]] = {}
|
||||||
client_versions = {}
|
client_versions: dict[int, tuple[int, int, int]] = {}
|
||||||
games = {}
|
games: dict[int, str] = {}
|
||||||
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
|
minimum_versions: NetUtils.MinimumVersions = {
|
||||||
slot_info = {}
|
"server": AutoWorld.World.required_server_version, "clients": client_versions
|
||||||
|
}
|
||||||
|
slot_info: dict[int, NetUtils.NetworkSlot] = {}
|
||||||
names = [[name for player, name in sorted(multiworld.player_name.items())]]
|
names = [[name for player, name in sorted(multiworld.player_name.items())]]
|
||||||
for slot in multiworld.player_ids:
|
for slot in multiworld.player_ids:
|
||||||
player_world: AutoWorld.World = multiworld.worlds[slot]
|
player_world: AutoWorld.World = multiworld.worlds[slot]
|
||||||
@@ -263,7 +262,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
group_members=sorted(group["players"]))
|
group_members=sorted(group["players"]))
|
||||||
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
|
||||||
for player, world_precollected in multiworld.precollected_items.items()}
|
for player, world_precollected in multiworld.precollected_items.items()}
|
||||||
precollected_hints = {player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))}
|
precollected_hints: dict[int, set[NetUtils.Hint]] = {
|
||||||
|
player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))
|
||||||
|
}
|
||||||
|
|
||||||
for slot in multiworld.player_ids:
|
for slot in multiworld.player_ids:
|
||||||
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
|
||||||
@@ -279,7 +280,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
for player in multiworld.groups[location.item.player]["players"]:
|
for player in multiworld.groups[location.item.player]["players"]:
|
||||||
precollected_hints[player].add(hint)
|
precollected_hints[player].add(hint)
|
||||||
|
|
||||||
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
|
||||||
for location in multiworld.get_filled_locations():
|
for location in multiworld.get_filled_locations():
|
||||||
if type(location.address) == int:
|
if type(location.address) == int:
|
||||||
assert location.item.code is not None, "item code None should be event, " \
|
assert location.item.code is not None, "item code None should be event, " \
|
||||||
@@ -306,20 +307,21 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
game_world.game: worlds.network_data_package["games"][game_world.game]
|
game_world.game: worlds.network_data_package["games"][game_world.game]
|
||||||
for game_world in multiworld.worlds.values()
|
for game_world in multiworld.worlds.values()
|
||||||
}
|
}
|
||||||
|
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
|
||||||
|
|
||||||
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
|
checks_in_area: dict[int, dict[str, int | list[int]]] = {}
|
||||||
|
|
||||||
# get spheres -> filter address==None -> skip empty
|
# get spheres -> filter address==None -> skip empty
|
||||||
spheres: List[Dict[int, Set[int]]] = []
|
spheres: list[dict[int, set[int]]] = []
|
||||||
for sphere in multiworld.get_sendable_spheres():
|
for sphere in multiworld.get_sendable_spheres():
|
||||||
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
|
current_sphere: dict[int, set[int]] = collections.defaultdict(set)
|
||||||
for sphere_location in sphere:
|
for sphere_location in sphere:
|
||||||
current_sphere[sphere_location.player].add(sphere_location.address)
|
current_sphere[sphere_location.player].add(sphere_location.address)
|
||||||
|
|
||||||
if current_sphere:
|
if current_sphere:
|
||||||
spheres.append(dict(current_sphere))
|
spheres.append(dict(current_sphere))
|
||||||
|
|
||||||
multidata = {
|
multidata: NetUtils.MultiData | bytes = {
|
||||||
"slot_data": slot_data,
|
"slot_data": slot_data,
|
||||||
"slot_info": slot_info,
|
"slot_info": slot_info,
|
||||||
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
|
"connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
|
||||||
@@ -329,7 +331,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
"er_hint_data": er_hint_data,
|
"er_hint_data": er_hint_data,
|
||||||
"precollected_items": precollected_items,
|
"precollected_items": precollected_items,
|
||||||
"precollected_hints": precollected_hints,
|
"precollected_hints": precollected_hints,
|
||||||
"version": tuple(version_tuple),
|
"version": (version_tuple.major, version_tuple.minor, version_tuple.build),
|
||||||
"tags": ["AP"],
|
"tags": ["AP"],
|
||||||
"minimum_versions": minimum_versions,
|
"minimum_versions": minimum_versions,
|
||||||
"seed_name": multiworld.seed_name,
|
"seed_name": multiworld.seed_name,
|
||||||
@@ -337,9 +339,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
"datapackage": data_package,
|
"datapackage": data_package,
|
||||||
"race_mode": int(multiworld.is_race),
|
"race_mode": int(multiworld.is_race),
|
||||||
}
|
}
|
||||||
|
# TODO: change to `"version": version_tuple` after getting better serialization
|
||||||
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
|
||||||
|
|
||||||
multidata = zlib.compress(pickle.dumps(multidata), 9)
|
for key in ("slot_data", "er_hint_data"):
|
||||||
|
multidata[key] = convert_to_base_types(multidata[key])
|
||||||
|
|
||||||
|
multidata = zlib.compress(restricted_dumps(multidata), 9)
|
||||||
|
|
||||||
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
||||||
f.write(bytes([3])) # version of format
|
f.write(bytes([3])) # version of format
|
||||||
|
|||||||
@@ -1,344 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
import atexit
|
|
||||||
import shutil
|
|
||||||
from subprocess import Popen
|
|
||||||
from shutil import copyfile
|
|
||||||
from time import strftime
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
import Utils
|
|
||||||
from Utils import is_windows
|
|
||||||
|
|
||||||
atexit.register(input, "Press enter to exit.")
|
|
||||||
|
|
||||||
# 1 or more digits followed by m or g, then optional b
|
|
||||||
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_yes_no(prompt):
|
|
||||||
yes_inputs = {'yes', 'ye', 'y'}
|
|
||||||
no_inputs = {'no', 'n'}
|
|
||||||
while True:
|
|
||||||
choice = input(prompt + " [y/n] ").lower()
|
|
||||||
if choice in yes_inputs:
|
|
||||||
return True
|
|
||||||
elif choice in no_inputs:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
print('Please respond with "y" or "n".')
|
|
||||||
|
|
||||||
|
|
||||||
def find_ap_randomizer_jar(forge_dir):
|
|
||||||
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
|
|
||||||
mods_dir = os.path.join(forge_dir, 'mods')
|
|
||||||
if os.path.isdir(mods_dir):
|
|
||||||
for entry in os.scandir(mods_dir):
|
|
||||||
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
|
|
||||||
logging.info(f"Found AP randomizer mod: {entry.name}")
|
|
||||||
return entry.name
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
os.mkdir(mods_dir)
|
|
||||||
logging.info(f"Created mods folder in {forge_dir}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def replace_apmc_files(forge_dir, apmc_file):
|
|
||||||
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
|
|
||||||
if apmc_file is None:
|
|
||||||
return
|
|
||||||
apdata_dir = os.path.join(forge_dir, 'APData')
|
|
||||||
copy_apmc = True
|
|
||||||
if not os.path.isdir(apdata_dir):
|
|
||||||
os.mkdir(apdata_dir)
|
|
||||||
logging.info(f"Created APData folder in {forge_dir}")
|
|
||||||
for entry in os.scandir(apdata_dir):
|
|
||||||
if entry.name.endswith(".apmc") and entry.is_file():
|
|
||||||
if not os.path.samefile(apmc_file, entry.path):
|
|
||||||
os.remove(entry.path)
|
|
||||||
logging.info(f"Removed {entry.name} in {apdata_dir}")
|
|
||||||
else: # apmc already in apdata
|
|
||||||
copy_apmc = False
|
|
||||||
if copy_apmc:
|
|
||||||
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
|
|
||||||
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
|
|
||||||
|
|
||||||
|
|
||||||
def read_apmc_file(apmc_file):
|
|
||||||
from base64 import b64decode
|
|
||||||
|
|
||||||
with open(apmc_file, 'r') as f:
|
|
||||||
return json.loads(b64decode(f.read()))
|
|
||||||
|
|
||||||
|
|
||||||
def update_mod(forge_dir, url: str):
|
|
||||||
"""Check mod version, download new mod from GitHub releases page if needed. """
|
|
||||||
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
|
||||||
os.path.basename(url)
|
|
||||||
if ap_randomizer is not None:
|
|
||||||
logging.info(f"Your current mod is {ap_randomizer}.")
|
|
||||||
else:
|
|
||||||
logging.info(f"You do not have the AP randomizer mod installed.")
|
|
||||||
|
|
||||||
if ap_randomizer != os.path.basename(url):
|
|
||||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
|
||||||
f"{os.path.basename(url)}")
|
|
||||||
if prompt_yes_no("Would you like to update?"):
|
|
||||||
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
|
|
||||||
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
|
|
||||||
logging.info("Downloading AP randomizer mod. This may take a moment...")
|
|
||||||
apmod_resp = requests.get(url)
|
|
||||||
if apmod_resp.status_code == 200:
|
|
||||||
with open(new_ap_mod, 'wb') as f:
|
|
||||||
f.write(apmod_resp.content)
|
|
||||||
logging.info(f"Wrote new mod file to {new_ap_mod}")
|
|
||||||
if old_ap_mod is not None:
|
|
||||||
os.remove(old_ap_mod)
|
|
||||||
logging.info(f"Removed old mod file from {old_ap_mod}")
|
|
||||||
else:
|
|
||||||
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
|
||||||
logging.error(f"Please report this issue on the Archipelago Discord server.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def check_eula(forge_dir):
|
|
||||||
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
|
|
||||||
eula_path = os.path.join(forge_dir, "eula.txt")
|
|
||||||
if not os.path.isfile(eula_path):
|
|
||||||
# Create eula.txt
|
|
||||||
with open(eula_path, 'w') as f:
|
|
||||||
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
|
|
||||||
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
|
|
||||||
f.write("eula=false\n")
|
|
||||||
with open(eula_path, 'r+') as f:
|
|
||||||
text = f.read()
|
|
||||||
if 'false' in text:
|
|
||||||
# Prompt user to agree to the EULA
|
|
||||||
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
|
|
||||||
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
|
|
||||||
if prompt_yes_no("Do you agree to the EULA?"):
|
|
||||||
f.seek(0)
|
|
||||||
f.write(text.replace('false', 'true'))
|
|
||||||
f.truncate()
|
|
||||||
logging.info(f"Set {eula_path} to true")
|
|
||||||
else:
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def find_jdk_dir(version: str) -> str:
|
|
||||||
"""get the specified versions jdk directory"""
|
|
||||||
for entry in os.listdir():
|
|
||||||
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
|
|
||||||
return os.path.abspath(entry)
|
|
||||||
|
|
||||||
|
|
||||||
def find_jdk(version: str) -> str:
|
|
||||||
"""get the java exe location"""
|
|
||||||
|
|
||||||
if is_windows:
|
|
||||||
jdk = find_jdk_dir(version)
|
|
||||||
jdk_exe = os.path.join(jdk, "bin", "java.exe")
|
|
||||||
if os.path.isfile(jdk_exe):
|
|
||||||
return jdk_exe
|
|
||||||
else:
|
|
||||||
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
|
|
||||||
if not jdk_exe:
|
|
||||||
raise Exception("Could not find Java. Is Java installed on the system?")
|
|
||||||
return jdk_exe
|
|
||||||
|
|
||||||
|
|
||||||
def download_java(java: str):
|
|
||||||
"""Download Corretto (Amazon JDK)"""
|
|
||||||
|
|
||||||
jdk = find_jdk_dir(java)
|
|
||||||
if jdk is not None:
|
|
||||||
print(f"Removing old JDK...")
|
|
||||||
from shutil import rmtree
|
|
||||||
rmtree(jdk)
|
|
||||||
|
|
||||||
print(f"Downloading Java...")
|
|
||||||
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
|
|
||||||
resp = requests.get(jdk_url)
|
|
||||||
if resp.status_code == 200: # OK
|
|
||||||
print(f"Extracting...")
|
|
||||||
import zipfile
|
|
||||||
from io import BytesIO
|
|
||||||
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
|
|
||||||
zf.extractall()
|
|
||||||
else:
|
|
||||||
print(f"Error downloading Java (status code {resp.status_code}).")
|
|
||||||
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
|
|
||||||
if not prompt_yes_no("Continue anyways?"):
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def install_forge(directory: str, forge_version: str, java_version: str):
|
|
||||||
"""download and install forge"""
|
|
||||||
|
|
||||||
java_exe = find_jdk(java_version)
|
|
||||||
if java_exe is not None:
|
|
||||||
print(f"Downloading Forge {forge_version}...")
|
|
||||||
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
|
|
||||||
resp = requests.get(forge_url)
|
|
||||||
if resp.status_code == 200: # OK
|
|
||||||
forge_install_jar = os.path.join(directory, "forge_install.jar")
|
|
||||||
if not os.path.exists(directory):
|
|
||||||
os.mkdir(directory)
|
|
||||||
with open(forge_install_jar, 'wb') as f:
|
|
||||||
f.write(resp.content)
|
|
||||||
print(f"Installing Forge...")
|
|
||||||
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
|
|
||||||
install_process.wait()
|
|
||||||
os.remove(forge_install_jar)
|
|
||||||
|
|
||||||
|
|
||||||
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
|
|
||||||
"""Run the Forge server."""
|
|
||||||
|
|
||||||
java_exe = find_jdk(java_version)
|
|
||||||
if not os.path.isfile(java_exe):
|
|
||||||
java_exe = "java" # try to fall back on java in the PATH
|
|
||||||
|
|
||||||
heap_arg = max_heap_re.match(heap_arg).group()
|
|
||||||
if heap_arg[-1] in ['b', 'B']:
|
|
||||||
heap_arg = heap_arg[:-1]
|
|
||||||
heap_arg = "-Xmx" + heap_arg
|
|
||||||
|
|
||||||
os_args = "win_args.txt" if is_windows else "unix_args.txt"
|
|
||||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
|
|
||||||
forge_args = []
|
|
||||||
with open(args_file) as argfile:
|
|
||||||
for line in argfile:
|
|
||||||
forge_args.extend(line.strip().split(" "))
|
|
||||||
|
|
||||||
args = [java_exe, heap_arg, *forge_args, "-nogui"]
|
|
||||||
logging.info(f"Running Forge server: {args}")
|
|
||||||
os.chdir(forge_dir)
|
|
||||||
return Popen(args)
|
|
||||||
|
|
||||||
|
|
||||||
def get_minecraft_versions(version, release_channel="release"):
|
|
||||||
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
|
|
||||||
resp = requests.get(version_file_endpoint)
|
|
||||||
local = False
|
|
||||||
if resp.status_code == 200: # OK
|
|
||||||
try:
|
|
||||||
data = resp.json()
|
|
||||||
except requests.exceptions.JSONDecodeError:
|
|
||||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
|
||||||
local = True
|
|
||||||
else:
|
|
||||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
|
||||||
local = True
|
|
||||||
|
|
||||||
if local:
|
|
||||||
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
else:
|
|
||||||
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
|
|
||||||
json.dump(data, f)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if version:
|
|
||||||
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
|
||||||
else:
|
|
||||||
return resp.json()[release_channel][0]
|
|
||||||
except (StopIteration, KeyError):
|
|
||||||
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
|
|
||||||
if release_channel != "release":
|
|
||||||
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
|
|
||||||
else:
|
|
||||||
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def is_correct_forge(forge_dir) -> bool:
|
|
||||||
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
Utils.init_logging("MinecraftClient")
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
|
|
||||||
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
|
|
||||||
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
|
|
||||||
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
|
|
||||||
help="Specify release channel to use.")
|
|
||||||
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
|
|
||||||
help="specify java version.")
|
|
||||||
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
|
|
||||||
help="specify forge version. (Minecraft Version-Forge Version)")
|
|
||||||
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
|
|
||||||
help="specify Mod data version to download.")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
|
|
||||||
|
|
||||||
# Change to executable's working directory
|
|
||||||
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
|
|
||||||
|
|
||||||
options = Utils.get_options()
|
|
||||||
channel = args.channel or options["minecraft_options"]["release_channel"]
|
|
||||||
apmc_data = None
|
|
||||||
data_version = args.data_version or None
|
|
||||||
|
|
||||||
if apmc_file is None and not args.install:
|
|
||||||
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
|
|
||||||
|
|
||||||
if apmc_file is not None and data_version is None:
|
|
||||||
apmc_data = read_apmc_file(apmc_file)
|
|
||||||
data_version = apmc_data.get('client_version', '')
|
|
||||||
|
|
||||||
versions = get_minecraft_versions(data_version, channel)
|
|
||||||
|
|
||||||
forge_dir = options["minecraft_options"]["forge_directory"]
|
|
||||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
|
||||||
forge_version = args.forge or versions["forge"]
|
|
||||||
java_version = args.java or versions["java"]
|
|
||||||
mod_url = versions["url"]
|
|
||||||
java_dir = find_jdk_dir(java_version)
|
|
||||||
|
|
||||||
if args.install:
|
|
||||||
if is_windows:
|
|
||||||
print("Installing Java")
|
|
||||||
download_java(java_version)
|
|
||||||
if not is_correct_forge(forge_dir):
|
|
||||||
print("Installing Minecraft Forge")
|
|
||||||
install_forge(forge_dir, forge_version, java_version)
|
|
||||||
else:
|
|
||||||
print("Correct Forge version already found, skipping install.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
if apmc_data is None:
|
|
||||||
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
|
|
||||||
|
|
||||||
if is_windows:
|
|
||||||
if java_dir is None or not os.path.isdir(java_dir):
|
|
||||||
if prompt_yes_no("Did not find java directory. Download and install java now?"):
|
|
||||||
download_java(java_version)
|
|
||||||
java_dir = find_jdk_dir(java_version)
|
|
||||||
if java_dir is None or not os.path.isdir(java_dir):
|
|
||||||
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
|
|
||||||
|
|
||||||
if not is_correct_forge(forge_dir):
|
|
||||||
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
|
|
||||||
install_forge(forge_dir, forge_version, java_version)
|
|
||||||
if not os.path.isdir(forge_dir):
|
|
||||||
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
|
|
||||||
|
|
||||||
if not max_heap_re.match(max_heap):
|
|
||||||
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
|
|
||||||
|
|
||||||
update_mod(forge_dir, mod_url)
|
|
||||||
replace_apmc_files(forge_dir, apmc_file)
|
|
||||||
check_eula(forge_dir)
|
|
||||||
server_process = run_forge_server(forge_dir, java_version, max_heap)
|
|
||||||
server_process.wait()
|
|
||||||
@@ -5,18 +5,22 @@ import multiprocessing
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
|
||||||
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
|
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9):
|
||||||
# Official micro version updates. This should match the number in docs/running from source.md.
|
# 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.")
|
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.11.9+ is supported.")
|
||||||
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15):
|
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.
|
# 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.")
|
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
|
||||||
elif sys.version_info < (3, 10, 1):
|
elif sys.version_info < (3, 11, 0):
|
||||||
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
|
# 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+ is supported.")
|
||||||
|
|
||||||
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
||||||
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
|
_skip_update = bool(
|
||||||
|
getattr(sys, "frozen", False) or
|
||||||
|
multiprocessing.parent_process() or
|
||||||
|
os.environ.get("SKIP_REQUIREMENTS_UPDATE", "").lower() in ("1", "true", "yes")
|
||||||
|
)
|
||||||
update_ran = _skip_update
|
update_ran = _skip_update
|
||||||
|
|
||||||
|
|
||||||
@@ -70,11 +74,11 @@ def update_command():
|
|||||||
def install_pkg_resources(yes=False):
|
def install_pkg_resources(yes=False):
|
||||||
try:
|
try:
|
||||||
import pkg_resources # noqa: F401
|
import pkg_resources # noqa: F401
|
||||||
except ImportError:
|
except (AttributeError, ImportError):
|
||||||
check_pip()
|
check_pip()
|
||||||
if not yes:
|
if not yes:
|
||||||
confirm("pkg_resources not found, press enter to install it")
|
confirm("pkg_resources not found, press enter to install it")
|
||||||
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
|
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"])
|
||||||
|
|
||||||
|
|
||||||
def update(yes: bool = False, force: bool = False) -> None:
|
def update(yes: bool = False, force: bool = False) -> None:
|
||||||
|
|||||||
116
MultiServer.py
116
MultiServer.py
@@ -43,10 +43,11 @@ import NetUtils
|
|||||||
import Utils
|
import Utils
|
||||||
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
||||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||||
SlotType, LocationStore, Hint, HintStatus
|
SlotType, LocationStore, MultiData, Hint, HintStatus
|
||||||
from BaseClasses import ItemClassification
|
from BaseClasses import ItemClassification
|
||||||
|
|
||||||
min_client_version = Version(0, 1, 6)
|
|
||||||
|
min_client_version = Version(0, 5, 0)
|
||||||
colorama.just_fix_windows_console()
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
|
|
||||||
@@ -66,9 +67,13 @@ def pop_from_container(container, value):
|
|||||||
return container
|
return container
|
||||||
|
|
||||||
|
|
||||||
def update_dict(dictionary, entries):
|
def update_container_unique(container, entries):
|
||||||
dictionary.update(entries)
|
if isinstance(container, list):
|
||||||
return dictionary
|
existing_container_as_set = set(container)
|
||||||
|
container.extend([entry for entry in entries if entry not in existing_container_as_set])
|
||||||
|
else:
|
||||||
|
container.update(entries)
|
||||||
|
return container
|
||||||
|
|
||||||
|
|
||||||
def queue_gc():
|
def queue_gc():
|
||||||
@@ -109,7 +114,7 @@ modify_functions = {
|
|||||||
# lists/dicts:
|
# lists/dicts:
|
||||||
"remove": remove_from_list,
|
"remove": remove_from_list,
|
||||||
"pop": pop_from_container,
|
"pop": pop_from_container,
|
||||||
"update": update_dict,
|
"update": update_container_unique,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -440,7 +445,7 @@ class Context:
|
|||||||
raise Utils.VersionException("Incompatible multidata.")
|
raise Utils.VersionException("Incompatible multidata.")
|
||||||
return restricted_loads(zlib.decompress(data[1:]))
|
return restricted_loads(zlib.decompress(data[1:]))
|
||||||
|
|
||||||
def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
|
def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any],
|
||||||
use_embedded_server_options: bool):
|
use_embedded_server_options: bool):
|
||||||
|
|
||||||
self.read_data = {}
|
self.read_data = {}
|
||||||
@@ -453,8 +458,12 @@ class Context:
|
|||||||
self.generator_version = Version(*decoded_obj["version"])
|
self.generator_version = Version(*decoded_obj["version"])
|
||||||
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
||||||
self.minimum_client_versions = {}
|
self.minimum_client_versions = {}
|
||||||
|
if self.generator_version < Version(0, 6, 2):
|
||||||
|
min_version = Version(0, 1, 6)
|
||||||
|
else:
|
||||||
|
min_version = min_client_version
|
||||||
for player, version in clients_ver.items():
|
for player, version in clients_ver.items():
|
||||||
self.minimum_client_versions[player] = max(Version(*version), min_client_version)
|
self.minimum_client_versions[player] = max(Version(*version), min_version)
|
||||||
|
|
||||||
self.slot_info = decoded_obj["slot_info"]
|
self.slot_info = decoded_obj["slot_info"]
|
||||||
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
||||||
@@ -537,6 +546,7 @@ class Context:
|
|||||||
|
|
||||||
def _save(self, exit_save: bool = False) -> bool:
|
def _save(self, exit_save: bool = False) -> bool:
|
||||||
try:
|
try:
|
||||||
|
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
|
||||||
encoded_save = pickle.dumps(self.get_save())
|
encoded_save = pickle.dumps(self.get_save())
|
||||||
with open(self.save_filename, "wb") as f:
|
with open(self.save_filename, "wb") as f:
|
||||||
f.write(zlib.compress(encoded_save))
|
f.write(zlib.compress(encoded_save))
|
||||||
@@ -743,7 +753,7 @@ class Context:
|
|||||||
return self.player_names[team, slot]
|
return self.player_names[team, slot]
|
||||||
|
|
||||||
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
|
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
|
||||||
recipients: typing.Sequence[int] = None):
|
persist_even_if_found: bool = False, recipients: typing.Sequence[int] = None):
|
||||||
"""Send and remember hints."""
|
"""Send and remember hints."""
|
||||||
if only_new:
|
if only_new:
|
||||||
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
|
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
|
||||||
@@ -758,8 +768,9 @@ class Context:
|
|||||||
if not hint.local and data not in concerns[hint.finding_player]:
|
if not hint.local and data not in concerns[hint.finding_player]:
|
||||||
concerns[hint.finding_player].append(data)
|
concerns[hint.finding_player].append(data)
|
||||||
|
|
||||||
# only remember hints that were not already found at the time of creation
|
# For !hint use cases, only hints that were not already found at the time of creation should be remembered
|
||||||
if not hint.found:
|
# 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,
|
# since hints are bidirectional, finding player and receiving player,
|
||||||
# we can check once if hint already exists
|
# we can check once if hint already exists
|
||||||
if hint not in self.hints[team, hint.finding_player]:
|
if hint not in self.hints[team, hint.finding_player]:
|
||||||
@@ -1821,7 +1832,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
ctx.clients[team][slot].append(client)
|
ctx.clients[team][slot].append(client)
|
||||||
client.version = args['version']
|
client.version = args['version']
|
||||||
client.tags = args['tags']
|
client.tags = args['tags']
|
||||||
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
|
client.no_locations = bool(client.tags & _non_game_messages.keys())
|
||||||
# set NoText for old PopTracker clients that predate the tag to save traffic
|
# set NoText for old PopTracker clients that predate the tag to save traffic
|
||||||
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
||||||
connected_packet = {
|
connected_packet = {
|
||||||
@@ -1895,7 +1906,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
old_tags = client.tags
|
old_tags = client.tags
|
||||||
client.tags = args["tags"]
|
client.tags = args["tags"]
|
||||||
if set(old_tags) != set(client.tags):
|
if set(old_tags) != set(client.tags):
|
||||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
client.no_locations = bool(client.tags & _non_game_messages.keys())
|
||||||
client.no_text = "NoText" in client.tags or (
|
client.no_text = "NoText" in client.tags or (
|
||||||
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
||||||
)
|
)
|
||||||
@@ -1937,10 +1948,62 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
|
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
|
||||||
HintStatus.HINT_UNSPECIFIED))
|
HintStatus.HINT_UNSPECIFIED))
|
||||||
locs.append(NetworkItem(target_item, location, target_player, flags))
|
locs.append(NetworkItem(target_item, location, target_player, flags))
|
||||||
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
|
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
|
||||||
if locs and create_as_hint:
|
if locs and create_as_hint:
|
||||||
ctx.save()
|
ctx.save()
|
||||||
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
||||||
|
|
||||||
|
elif cmd == 'CreateHints':
|
||||||
|
location_player = args.get("player", client.slot)
|
||||||
|
locations = args["locations"]
|
||||||
|
status = args.get("status", HintStatus.HINT_UNSPECIFIED)
|
||||||
|
|
||||||
|
if not locations:
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": "CreateHints: No locations specified.", "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = HintStatus(status)
|
||||||
|
except ValueError as err:
|
||||||
|
await ctx.send_msgs(client,
|
||||||
|
[{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": f"Unknown Status: {err}",
|
||||||
|
"original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
hints = []
|
||||||
|
|
||||||
|
for location in locations:
|
||||||
|
if location_player != client.slot and location not in ctx.locations[location_player]:
|
||||||
|
error_text = (
|
||||||
|
"CreateHints: One or more of the locations do not exist for the specified off-world player. "
|
||||||
|
"Please refrain from hinting other slot's locations that you don't know contain your items."
|
||||||
|
)
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": error_text, "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
target_item, item_player, flags = ctx.locations[location_player][location]
|
||||||
|
|
||||||
|
if client.slot not in ctx.slot_set(item_player):
|
||||||
|
if status != HintStatus.HINT_UNSPECIFIED:
|
||||||
|
error_text = 'CreateHints: Must use "unspecified"/None status for items from other players.'
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": error_text, "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
if client.slot != location_player:
|
||||||
|
error_text = "CreateHints: Can only create hints for own items or own locations."
|
||||||
|
await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
|
||||||
|
"text": error_text, "original_cmd": cmd}])
|
||||||
|
return
|
||||||
|
|
||||||
|
hints += collect_hint_location_id(ctx, client.team, location_player, location, status)
|
||||||
|
|
||||||
|
# As of writing this code, only_new=True does not update status for existing hints
|
||||||
|
ctx.notify_hints(client.team, hints, only_new=True, persist_even_if_found=True)
|
||||||
|
ctx.save()
|
||||||
|
|
||||||
elif cmd == 'UpdateHint':
|
elif cmd == 'UpdateHint':
|
||||||
location = args["location"]
|
location = args["location"]
|
||||||
@@ -1978,14 +2041,21 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
new_hint = new_hint.re_prioritize(ctx, status)
|
new_hint = new_hint.re_prioritize(ctx, status)
|
||||||
if hint == new_hint:
|
if hint == new_hint:
|
||||||
return
|
return
|
||||||
ctx.replace_hint(client.team, hint.finding_player, hint, new_hint)
|
|
||||||
ctx.replace_hint(client.team, hint.receiving_player, hint, new_hint)
|
concerning_slots = ctx.slot_set(hint.receiving_player) | {hint.finding_player}
|
||||||
|
for slot in concerning_slots:
|
||||||
|
ctx.replace_hint(client.team, slot, hint, new_hint)
|
||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.on_changed_hints(client.team, hint.finding_player)
|
for slot in concerning_slots:
|
||||||
ctx.on_changed_hints(client.team, hint.receiving_player)
|
ctx.on_changed_hints(client.team, slot)
|
||||||
|
|
||||||
elif cmd == 'StatusUpdate':
|
elif cmd == 'StatusUpdate':
|
||||||
update_client_status(ctx, client, args["status"])
|
if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL:
|
||||||
|
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
|
||||||
|
"text": "Trackers can't register Goal Complete",
|
||||||
|
"original_cmd": cmd}])
|
||||||
|
else:
|
||||||
|
update_client_status(ctx, client, args["status"])
|
||||||
|
|
||||||
elif cmd == 'Say':
|
elif cmd == 'Say':
|
||||||
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
||||||
@@ -2037,7 +2107,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
value = func(value, operation["value"])
|
value = func(value, operation["value"])
|
||||||
ctx.stored_data[args["key"]] = args["value"] = value
|
ctx.stored_data[args["key"]] = args["value"] = value
|
||||||
targets = set(ctx.stored_data_notification_clients[args["key"]])
|
targets = set(ctx.stored_data_notification_clients[args["key"]])
|
||||||
if args.get("want_reply", True):
|
if args.get("want_reply", False):
|
||||||
targets.add(client)
|
targets.add(client)
|
||||||
if targets:
|
if targets:
|
||||||
ctx.broadcast(targets, [args])
|
ctx.broadcast(targets, [args])
|
||||||
@@ -2412,8 +2482,10 @@ async def console(ctx: Context):
|
|||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
|
from settings import get_settings
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
defaults = Utils.get_settings()["server_options"].as_dict()
|
defaults = get_settings().server_options.as_dict()
|
||||||
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
|
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
|
||||||
parser.add_argument('--host', default=defaults["host"])
|
parser.add_argument('--host', default=defaults["host"])
|
||||||
parser.add_argument('--port', default=defaults["port"], type=int)
|
parser.add_argument('--port', default=defaults["port"], type=int)
|
||||||
|
|||||||
60
NetUtils.py
60
NetUtils.py
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
import typing
|
import typing
|
||||||
import enum
|
import enum
|
||||||
import warnings
|
import warnings
|
||||||
@@ -83,7 +84,7 @@ class NetworkSlot(typing.NamedTuple):
|
|||||||
name: str
|
name: str
|
||||||
game: str
|
game: str
|
||||||
type: SlotType
|
type: SlotType
|
||||||
group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
|
group_members: Sequence[int] = () # only populated if type == group
|
||||||
|
|
||||||
|
|
||||||
class NetworkItem(typing.NamedTuple):
|
class NetworkItem(typing.NamedTuple):
|
||||||
@@ -106,6 +107,27 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"]
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_base_types(obj: typing.Any) -> _base_types:
|
||||||
|
if isinstance(obj, (tuple, list, set, frozenset)):
|
||||||
|
return tuple(convert_to_base_types(o) for o in obj)
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()}
|
||||||
|
elif obj is None or type(obj) in (str, int, float, bool):
|
||||||
|
return obj
|
||||||
|
# unwrap simple types to their base, such as StrEnum
|
||||||
|
elif isinstance(obj, str):
|
||||||
|
return str(obj)
|
||||||
|
elif isinstance(obj, int):
|
||||||
|
return int(obj)
|
||||||
|
elif isinstance(obj, float):
|
||||||
|
return float(obj)
|
||||||
|
else:
|
||||||
|
raise Exception(f"Cannot handle {type(obj)}")
|
||||||
|
|
||||||
|
|
||||||
_encode = JSONEncoder(
|
_encode = JSONEncoder(
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
check_circular=False,
|
check_circular=False,
|
||||||
@@ -450,6 +472,42 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
|
|||||||
location_id not in checked])
|
location_id not in checked])
|
||||||
|
|
||||||
|
|
||||||
|
class MinimumVersions(typing.TypedDict):
|
||||||
|
server: tuple[int, int, int]
|
||||||
|
clients: dict[int, tuple[int, int, int]]
|
||||||
|
|
||||||
|
|
||||||
|
class GamesPackage(typing.TypedDict, total=False):
|
||||||
|
item_name_groups: dict[str, list[str]]
|
||||||
|
item_name_to_id: dict[str, int]
|
||||||
|
location_name_groups: dict[str, list[str]]
|
||||||
|
location_name_to_id: dict[str, int]
|
||||||
|
checksum: str
|
||||||
|
|
||||||
|
|
||||||
|
class DataPackage(typing.TypedDict):
|
||||||
|
games: dict[str, GamesPackage]
|
||||||
|
|
||||||
|
|
||||||
|
class MultiData(typing.TypedDict):
|
||||||
|
slot_data: dict[int, Mapping[str, typing.Any]]
|
||||||
|
slot_info: dict[int, NetworkSlot]
|
||||||
|
connect_names: dict[str, tuple[int, int]]
|
||||||
|
locations: dict[int, dict[int, tuple[int, int, int]]]
|
||||||
|
checks_in_area: dict[int, dict[str, int | list[int]]]
|
||||||
|
server_options: dict[str, object]
|
||||||
|
er_hint_data: dict[int, dict[int, str]]
|
||||||
|
precollected_items: dict[int, list[int]]
|
||||||
|
precollected_hints: dict[int, set[Hint]]
|
||||||
|
version: tuple[int, int, int]
|
||||||
|
tags: list[str]
|
||||||
|
minimum_versions: MinimumVersions
|
||||||
|
seed_name: str
|
||||||
|
spheres: list[dict[int, set[int]]]
|
||||||
|
datapackage: dict[str, GamesPackage]
|
||||||
|
race_mode: int
|
||||||
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
||||||
LocationStore = _LocationStore
|
LocationStore = _LocationStore
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from CommonClient import CommonContext, server_loop, gui_enabled, \
|
|||||||
import Utils
|
import Utils
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from worlds import network_data_package
|
from worlds import network_data_package
|
||||||
|
from worlds.oot import OOTWorld
|
||||||
from worlds.oot.Rom import Rom, compress_rom_file
|
from worlds.oot.Rom import Rom, compress_rom_file
|
||||||
from worlds.oot.N64Patch import apply_patch_file
|
from worlds.oot.N64Patch import apply_patch_file
|
||||||
from worlds.oot.Utils import data_path
|
from worlds.oot.Utils import data_path
|
||||||
@@ -276,11 +277,12 @@ async def n64_sync_task(ctx: OoTContext):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
async def run_game(romfile):
|
async def run_game(romfile):
|
||||||
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
|
auto_start = OOTWorld.settings.rom_start
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
@@ -295,7 +297,7 @@ async def patch_and_run_game(apz5_file):
|
|||||||
decomp_path = base_name + '-decomp.z64'
|
decomp_path = base_name + '-decomp.z64'
|
||||||
comp_path = base_name + '.z64'
|
comp_path = base_name + '.z64'
|
||||||
# Load vanilla ROM, patch file, compress ROM
|
# Load vanilla ROM, patch file, compress ROM
|
||||||
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
|
rom_file_name = OOTWorld.settings.rom_file
|
||||||
rom = Rom(rom_file_name)
|
rom = Rom(rom_file_name)
|
||||||
|
|
||||||
sub_file = None
|
sub_file = None
|
||||||
|
|||||||
305
Options.py
305
Options.py
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
|
import collections
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
@@ -23,6 +24,12 @@ if typing.TYPE_CHECKING:
|
|||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
|
|
||||||
|
def roll_percentage(percentage: int | float) -> bool:
|
||||||
|
"""Roll a percentage chance.
|
||||||
|
percentage is expected to be in range [0, 100]"""
|
||||||
|
return random.random() < (float(percentage) / 100)
|
||||||
|
|
||||||
|
|
||||||
class OptionError(ValueError):
|
class OptionError(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -487,6 +494,30 @@ class Choice(NumericOption):
|
|||||||
else:
|
else:
|
||||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||||
|
|
||||||
|
def __lt__(self, other: typing.Union[Choice, int, str]):
|
||||||
|
if isinstance(other, str):
|
||||||
|
assert other in self.options, f"compared against an unknown string. {self} < {other}"
|
||||||
|
other = self.options[other]
|
||||||
|
return super(Choice, self).__lt__(other)
|
||||||
|
|
||||||
|
def __gt__(self, other: typing.Union[Choice, int, str]):
|
||||||
|
if isinstance(other, str):
|
||||||
|
assert other in self.options, f"compared against an unknown string. {self} > {other}"
|
||||||
|
other = self.options[other]
|
||||||
|
return super(Choice, self).__gt__(other)
|
||||||
|
|
||||||
|
def __le__(self, other: typing.Union[Choice, int, str]):
|
||||||
|
if isinstance(other, str):
|
||||||
|
assert other in self.options, f"compared against an unknown string. {self} <= {other}"
|
||||||
|
other = self.options[other]
|
||||||
|
return super(Choice, self).__le__(other)
|
||||||
|
|
||||||
|
def __ge__(self, other: typing.Union[Choice, int, str]):
|
||||||
|
if isinstance(other, str):
|
||||||
|
assert other in self.options, f"compared against an unknown string. {self} >= {other}"
|
||||||
|
other = self.options[other]
|
||||||
|
return super(Choice, self).__ge__(other)
|
||||||
|
|
||||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||||
|
|
||||||
|
|
||||||
@@ -858,23 +889,57 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
|
|||||||
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
||||||
|
|
||||||
def __getitem__(self, item: str) -> typing.Any:
|
def __getitem__(self, item: str) -> typing.Any:
|
||||||
return self.value.__getitem__(item)
|
return self.value[item]
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[str]:
|
def __iter__(self) -> typing.Iterator[str]:
|
||||||
return self.value.__iter__()
|
return iter(self.value)
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self.value.__len__()
|
return len(self.value)
|
||||||
|
|
||||||
|
# __getitem__ fallback fails for Counters, so we define this explicitly
|
||||||
|
def __contains__(self, item) -> bool:
|
||||||
|
return item in self.value
|
||||||
|
|
||||||
|
|
||||||
class ItemDict(OptionDict):
|
class OptionCounter(OptionDict):
|
||||||
|
min: int | None = None
|
||||||
|
max: int | None = None
|
||||||
|
|
||||||
|
def __init__(self, value: dict[str, int]) -> None:
|
||||||
|
super(OptionCounter, self).__init__(collections.Counter(value))
|
||||||
|
|
||||||
|
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
|
||||||
|
super(OptionCounter, self).verify(world, player_name, plando_options)
|
||||||
|
|
||||||
|
range_errors = []
|
||||||
|
|
||||||
|
if self.max is not None:
|
||||||
|
range_errors += [
|
||||||
|
f"\"{key}: {value}\" is higher than maximum allowed value {self.max}."
|
||||||
|
for key, value in self.value.items() if value > self.max
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.min is not None:
|
||||||
|
range_errors += [
|
||||||
|
f"\"{key}: {value}\" is lower than minimum allowed value {self.min}."
|
||||||
|
for key, value in self.value.items() if value < self.min
|
||||||
|
]
|
||||||
|
|
||||||
|
if range_errors:
|
||||||
|
range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors
|
||||||
|
raise OptionError("\n".join(range_errors))
|
||||||
|
|
||||||
|
|
||||||
|
class ItemDict(OptionCounter):
|
||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
|
|
||||||
def __init__(self, value: typing.Dict[str, int]):
|
min = 0
|
||||||
if any(item_count is None for item_count in value.values()):
|
|
||||||
raise Exception("Items must have counts associated with them. Please provide positive integer values in the format \"item\": count .")
|
def __init__(self, value: dict[str, int]) -> None:
|
||||||
if any(item_count < 1 for item_count in value.values()):
|
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
|
||||||
raise Exception("Cannot have non-positive item counts.")
|
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
|
||||||
|
|
||||||
super(ItemDict, self).__init__(value)
|
super(ItemDict, self).__init__(value)
|
||||||
|
|
||||||
|
|
||||||
@@ -984,7 +1049,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
if isinstance(data, typing.Iterable):
|
if isinstance(data, typing.Iterable):
|
||||||
for text in data:
|
for text in data:
|
||||||
if isinstance(text, typing.Mapping):
|
if isinstance(text, typing.Mapping):
|
||||||
if random.random() < float(text.get("percentage", 100)/100):
|
if roll_percentage(text.get("percentage", 100)):
|
||||||
at = text.get("at", None)
|
at = text.get("at", None)
|
||||||
if at is not None:
|
if at is not None:
|
||||||
if isinstance(at, dict):
|
if isinstance(at, dict):
|
||||||
@@ -1010,7 +1075,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
else:
|
else:
|
||||||
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
|
||||||
elif isinstance(text, PlandoText):
|
elif isinstance(text, PlandoText):
|
||||||
if random.random() < float(text.percentage/100):
|
if roll_percentage(text.percentage):
|
||||||
texts.append(text)
|
texts.append(text)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
|
||||||
@@ -1026,10 +1091,10 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
|
|||||||
yield from self.value
|
yield from self.value
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
|
def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
|
||||||
return self.value.__getitem__(index)
|
return self.value[index]
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self.value.__len__()
|
return len(self.value)
|
||||||
|
|
||||||
|
|
||||||
class ConnectionsMeta(AssembleOptions):
|
class ConnectionsMeta(AssembleOptions):
|
||||||
@@ -1053,7 +1118,7 @@ class PlandoConnection(typing.NamedTuple):
|
|||||||
|
|
||||||
entrance: str
|
entrance: str
|
||||||
exit: str
|
exit: str
|
||||||
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
|
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped
|
||||||
percentage: int = 100
|
percentage: int = 100
|
||||||
|
|
||||||
|
|
||||||
@@ -1134,7 +1199,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
for connection in data:
|
for connection in data:
|
||||||
if isinstance(connection, typing.Mapping):
|
if isinstance(connection, typing.Mapping):
|
||||||
percentage = connection.get("percentage", 100)
|
percentage = connection.get("percentage", 100)
|
||||||
if random.random() < float(percentage / 100):
|
if roll_percentage(percentage):
|
||||||
entrance = connection.get("entrance", None)
|
entrance = connection.get("entrance", None)
|
||||||
if is_iterable_except_str(entrance):
|
if is_iterable_except_str(entrance):
|
||||||
entrance = random.choice(sorted(entrance))
|
entrance = random.choice(sorted(entrance))
|
||||||
@@ -1152,7 +1217,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
percentage
|
percentage
|
||||||
))
|
))
|
||||||
elif isinstance(connection, PlandoConnection):
|
elif isinstance(connection, PlandoConnection):
|
||||||
if random.random() < float(connection.percentage / 100):
|
if roll_percentage(connection.percentage):
|
||||||
value.append(connection)
|
value.append(connection)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
|
||||||
@@ -1176,7 +1241,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
|
|||||||
connection.exit) for connection in value])
|
connection.exit) for connection in value])
|
||||||
|
|
||||||
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
|
def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
|
||||||
return self.value.__getitem__(index)
|
return self.value[index]
|
||||||
|
|
||||||
def __iter__(self) -> typing.Iterator[PlandoConnection]:
|
def __iter__(self) -> typing.Iterator[PlandoConnection]:
|
||||||
yield from self.value
|
yield from self.value
|
||||||
@@ -1257,42 +1322,48 @@ class CommonOptions(metaclass=OptionsMetaProperty):
|
|||||||
progression_balancing: ProgressionBalancing
|
progression_balancing: ProgressionBalancing
|
||||||
accessibility: Accessibility
|
accessibility: Accessibility
|
||||||
|
|
||||||
def as_dict(self,
|
def as_dict(
|
||||||
*option_names: str,
|
self,
|
||||||
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
|
*option_names: str,
|
||||||
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
|
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
|
||||||
|
toggles_as_bools: bool = False,
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
"""
|
"""
|
||||||
Returns a dictionary of [str, Option.value]
|
Returns a dictionary of [str, Option.value]
|
||||||
|
|
||||||
:param option_names: names of the options to return
|
:param option_names: Names of the options to get the values of.
|
||||||
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
|
:param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
|
||||||
:param toggles_as_bools: whether toggle options should be output as bools instead of strings
|
:param toggles_as_bools: Whether toggle options should be returned as bools instead of ints.
|
||||||
|
|
||||||
|
:return: A dictionary of each option name to the value of its Option. If the option is an OptionSet, the value
|
||||||
|
will be returned as a sorted list.
|
||||||
"""
|
"""
|
||||||
assert option_names, "options.as_dict() was used without any option names."
|
assert option_names, "options.as_dict() was used without any option names."
|
||||||
|
assert len(option_names) < len(self.__class__.type_hints), "Specify only options you need."
|
||||||
option_results = {}
|
option_results = {}
|
||||||
for option_name in option_names:
|
for option_name in option_names:
|
||||||
if option_name in type(self).type_hints:
|
if option_name not in type(self).type_hints:
|
||||||
if casing == "snake":
|
|
||||||
display_name = option_name
|
|
||||||
elif casing == "camel":
|
|
||||||
split_name = [name.title() for name in option_name.split("_")]
|
|
||||||
split_name[0] = split_name[0].lower()
|
|
||||||
display_name = "".join(split_name)
|
|
||||||
elif casing == "pascal":
|
|
||||||
display_name = "".join([name.title() for name in option_name.split("_")])
|
|
||||||
elif casing == "kebab":
|
|
||||||
display_name = option_name.replace("_", "-")
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{casing} is invalid casing for as_dict. "
|
|
||||||
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
|
|
||||||
value = getattr(self, option_name).value
|
|
||||||
if isinstance(value, set):
|
|
||||||
value = sorted(value)
|
|
||||||
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
|
|
||||||
value = bool(value)
|
|
||||||
option_results[display_name] = value
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
|
||||||
|
|
||||||
|
if casing == "snake":
|
||||||
|
display_name = option_name
|
||||||
|
elif casing == "camel":
|
||||||
|
split_name = [name.title() for name in option_name.split("_")]
|
||||||
|
split_name[0] = split_name[0].lower()
|
||||||
|
display_name = "".join(split_name)
|
||||||
|
elif casing == "pascal":
|
||||||
|
display_name = "".join([name.title() for name in option_name.split("_")])
|
||||||
|
elif casing == "kebab":
|
||||||
|
display_name = option_name.replace("_", "-")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"{casing} is invalid casing for as_dict. "
|
||||||
|
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
|
||||||
|
value = getattr(self, option_name).value
|
||||||
|
if isinstance(value, set):
|
||||||
|
value = sorted(value)
|
||||||
|
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
|
||||||
|
value = bool(value)
|
||||||
|
option_results[display_name] = value
|
||||||
return option_results
|
return option_results
|
||||||
|
|
||||||
|
|
||||||
@@ -1313,6 +1384,7 @@ class StartInventory(ItemDict):
|
|||||||
verify_item_name = True
|
verify_item_name = True
|
||||||
display_name = "Start Inventory"
|
display_name = "Start Inventory"
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
|
max = 10000
|
||||||
|
|
||||||
|
|
||||||
class StartInventoryPool(StartInventory):
|
class StartInventoryPool(StartInventory):
|
||||||
@@ -1428,6 +1500,133 @@ class ItemLinks(OptionList):
|
|||||||
link["item_pool"] = list(pool)
|
link["item_pool"] = list(pool)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlandoItem:
|
||||||
|
items: list[str] | dict[str, typing.Any]
|
||||||
|
locations: list[str]
|
||||||
|
world: int | str | bool | None | typing.Iterable[str] | set[int] = False
|
||||||
|
from_pool: bool = True
|
||||||
|
force: bool | typing.Literal["silent"] = "silent"
|
||||||
|
count: int | bool | dict[str, int] = False
|
||||||
|
percentage: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
class PlandoItems(Option[typing.List[PlandoItem]]):
|
||||||
|
"""Generic items plando."""
|
||||||
|
default = ()
|
||||||
|
supports_weighting = False
|
||||||
|
display_name = "Plando Items"
|
||||||
|
|
||||||
|
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
|
||||||
|
self.value = list(deepcopy(value))
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
|
||||||
|
if not isinstance(data, typing.Iterable):
|
||||||
|
raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
|
||||||
|
|
||||||
|
value: typing.List[PlandoItem] = []
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, typing.Mapping):
|
||||||
|
percentage = item.get("percentage", 100)
|
||||||
|
if not isinstance(percentage, int):
|
||||||
|
raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
|
||||||
|
if not (0 <= percentage <= 100):
|
||||||
|
raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
|
||||||
|
if roll_percentage(percentage):
|
||||||
|
count = item.get("count", False)
|
||||||
|
items = item.get("items", [])
|
||||||
|
if not items:
|
||||||
|
items = item.get("item", None) # explicitly throw an error here if not present
|
||||||
|
if not items:
|
||||||
|
raise OptionError("You must specify at least one item to place items with plando.")
|
||||||
|
count = 1
|
||||||
|
if isinstance(items, str):
|
||||||
|
items = [items]
|
||||||
|
elif not isinstance(items, (dict, list)):
|
||||||
|
raise OptionError(f"Plando 'items' has to be string, list, or "
|
||||||
|
f"dictionary, not {type(items)}")
|
||||||
|
locations = item.get("locations", [])
|
||||||
|
if not locations:
|
||||||
|
locations = item.get("location", [])
|
||||||
|
if locations:
|
||||||
|
count = 1
|
||||||
|
else:
|
||||||
|
locations = ["Everywhere"]
|
||||||
|
if isinstance(locations, str):
|
||||||
|
locations = [locations]
|
||||||
|
if not isinstance(locations, list):
|
||||||
|
raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
|
||||||
|
world = item.get("world", False)
|
||||||
|
from_pool = item.get("from_pool", True)
|
||||||
|
force = item.get("force", "silent")
|
||||||
|
if not isinstance(from_pool, bool):
|
||||||
|
raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
|
||||||
|
if not (isinstance(force, bool) or force == "silent"):
|
||||||
|
raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
|
||||||
|
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
|
||||||
|
elif isinstance(item, PlandoItem):
|
||||||
|
if roll_percentage(item.percentage):
|
||||||
|
value.append(item)
|
||||||
|
else:
|
||||||
|
raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
|
||||||
|
return cls(value)
|
||||||
|
|
||||||
|
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||||
|
if not self.value:
|
||||||
|
return
|
||||||
|
from BaseClasses import PlandoOptions
|
||||||
|
if not (PlandoOptions.items & plando_options):
|
||||||
|
# plando is disabled but plando options were given so overwrite the options
|
||||||
|
self.value = []
|
||||||
|
logging.warning(f"The plando items module is turned off, "
|
||||||
|
f"so items for {player_name} will be ignored.")
|
||||||
|
else:
|
||||||
|
# filter down item groups
|
||||||
|
for plando in self.value:
|
||||||
|
# confirm a valid count
|
||||||
|
if isinstance(plando.count, dict):
|
||||||
|
if "min" in plando.count and "max" in plando.count:
|
||||||
|
if plando.count["min"] > plando.count["max"]:
|
||||||
|
raise OptionError("Plando cannot have count `min` greater than `max`.")
|
||||||
|
items_copy = plando.items.copy()
|
||||||
|
if isinstance(plando.items, dict):
|
||||||
|
for item in items_copy:
|
||||||
|
if item in world.item_name_groups:
|
||||||
|
value = plando.items.pop(item)
|
||||||
|
group = world.item_name_groups[item]
|
||||||
|
filtered_items = sorted(group.difference(list(plando.items.keys())))
|
||||||
|
if not filtered_items:
|
||||||
|
raise OptionError(f"Plando `items` contains the group \"{item}\" "
|
||||||
|
f"and every item in it. This is not allowed.")
|
||||||
|
if value is True:
|
||||||
|
for key in filtered_items:
|
||||||
|
plando.items[key] = True
|
||||||
|
else:
|
||||||
|
for key in random.choices(filtered_items, k=value):
|
||||||
|
plando.items[key] = plando.items.get(key, 0) + 1
|
||||||
|
else:
|
||||||
|
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
|
||||||
|
for item in items_copy:
|
||||||
|
if item in world.item_name_groups:
|
||||||
|
plando.items.remove(item)
|
||||||
|
plando.items.extend(sorted(world.item_name_groups[item]))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_option_name(cls, value: list[PlandoItem]) -> str:
|
||||||
|
return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
|
||||||
|
|
||||||
|
def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
|
||||||
|
return self.value.__getitem__(index)
|
||||||
|
|
||||||
|
def __iter__(self) -> typing.Iterator[PlandoItem]:
|
||||||
|
yield from self.value
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.value)
|
||||||
|
|
||||||
|
|
||||||
class Removed(FreeText):
|
class Removed(FreeText):
|
||||||
"""This Option has been Removed."""
|
"""This Option has been Removed."""
|
||||||
rich_text_doc = True
|
rich_text_doc = True
|
||||||
@@ -1450,6 +1649,7 @@ class PerGameCommonOptions(CommonOptions):
|
|||||||
exclude_locations: ExcludeLocations
|
exclude_locations: ExcludeLocations
|
||||||
priority_locations: PriorityLocations
|
priority_locations: PriorityLocations
|
||||||
item_links: ItemLinks
|
item_links: ItemLinks
|
||||||
|
plando_items: PlandoItems
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -1468,7 +1668,7 @@ class OptionGroup(typing.NamedTuple):
|
|||||||
|
|
||||||
|
|
||||||
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
|
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
|
||||||
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks]
|
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks, PlandoItems]
|
||||||
"""
|
"""
|
||||||
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
|
Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
|
||||||
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
|
If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
|
||||||
@@ -1503,6 +1703,7 @@ def get_option_groups(world: typing.Type[World], visibility_level: Visibility =
|
|||||||
|
|
||||||
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
|
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
|
||||||
import os
|
import os
|
||||||
|
from inspect import cleandoc
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
@@ -1541,19 +1742,21 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
|
|||||||
# yaml dump may add end of document marker and newlines.
|
# yaml dump may add end of document marker and newlines.
|
||||||
return yaml.dump(scalar).replace("...\n", "").strip()
|
return yaml.dump(scalar).replace("...\n", "").strip()
|
||||||
|
|
||||||
|
with open(local_path("data", "options.yaml")) as f:
|
||||||
|
file_data = f.read()
|
||||||
|
template = Template(file_data)
|
||||||
|
|
||||||
for game_name, world in AutoWorldRegister.world_types.items():
|
for game_name, world in AutoWorldRegister.world_types.items():
|
||||||
if not world.hidden or generate_hidden:
|
if not world.hidden or generate_hidden:
|
||||||
option_groups = get_option_groups(world)
|
option_groups = get_option_groups(world)
|
||||||
with open(local_path("data", "options.yaml")) as f:
|
|
||||||
file_data = f.read()
|
res = template.render(
|
||||||
res = Template(file_data).render(
|
|
||||||
option_groups=option_groups,
|
option_groups=option_groups,
|
||||||
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
|
||||||
dictify_range=dictify_range,
|
dictify_range=dictify_range,
|
||||||
|
cleandoc=cleandoc,
|
||||||
)
|
)
|
||||||
|
|
||||||
del file_data
|
|
||||||
|
|
||||||
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
|
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||||
f.write(res)
|
f.write(res)
|
||||||
|
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -7,23 +7,19 @@ Currently, the following games are supported:
|
|||||||
|
|
||||||
* The Legend of Zelda: A Link to the Past
|
* The Legend of Zelda: A Link to the Past
|
||||||
* Factorio
|
* Factorio
|
||||||
* Minecraft
|
|
||||||
* Subnautica
|
* Subnautica
|
||||||
* Slay the Spire
|
|
||||||
* Risk of Rain 2
|
* Risk of Rain 2
|
||||||
* The Legend of Zelda: Ocarina of Time
|
* The Legend of Zelda: Ocarina of Time
|
||||||
* Timespinner
|
* Timespinner
|
||||||
* Super Metroid
|
* Super Metroid
|
||||||
* Secret of Evermore
|
* Secret of Evermore
|
||||||
* Final Fantasy
|
* Final Fantasy
|
||||||
* Rogue Legacy
|
|
||||||
* VVVVVV
|
* VVVVVV
|
||||||
* Raft
|
* Raft
|
||||||
* Super Mario 64
|
* Super Mario 64
|
||||||
* Meritous
|
* Meritous
|
||||||
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
||||||
* ChecksFinder
|
* ChecksFinder
|
||||||
* ArchipIDLE
|
|
||||||
* Hollow Knight
|
* Hollow Knight
|
||||||
* The Witness
|
* The Witness
|
||||||
* Sonic Adventure 2: Battle
|
* Sonic Adventure 2: Battle
|
||||||
@@ -43,7 +39,6 @@ Currently, the following games are supported:
|
|||||||
* The Messenger
|
* The Messenger
|
||||||
* Kingdom Hearts 2
|
* Kingdom Hearts 2
|
||||||
* The Legend of Zelda: Link's Awakening DX
|
* The Legend of Zelda: Link's Awakening DX
|
||||||
* Clique
|
|
||||||
* Adventure
|
* Adventure
|
||||||
* DLC Quest
|
* DLC Quest
|
||||||
* Noita
|
* Noita
|
||||||
@@ -63,7 +58,6 @@ Currently, the following games are supported:
|
|||||||
* TUNIC
|
* TUNIC
|
||||||
* Kirby's Dream Land 3
|
* Kirby's Dream Land 3
|
||||||
* Celeste 64
|
* Celeste 64
|
||||||
* Zork Grand Inquisitor
|
|
||||||
* Castlevania 64
|
* Castlevania 64
|
||||||
* A Short Hike
|
* A Short Hike
|
||||||
* Yoshi's Island
|
* Yoshi's Island
|
||||||
@@ -81,6 +75,13 @@ Currently, the following games are supported:
|
|||||||
* Castlevania: Circle of the Moon
|
* Castlevania: Circle of the Moon
|
||||||
* Inscryption
|
* Inscryption
|
||||||
* Civilization VI
|
* Civilization VI
|
||||||
|
* The Legend of Zelda: The Wind Waker
|
||||||
|
* Jak and Daxter: The Precursor Legacy
|
||||||
|
* Super Mario Land 2: 6 Golden Coins
|
||||||
|
* shapez
|
||||||
|
* Paint
|
||||||
|
* Celeste (Open World)
|
||||||
|
* Choo-Choo Charles
|
||||||
|
|
||||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from json import loads, dumps
|
|||||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
from settings import Settings
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from MultiServer import mark_raw
|
from MultiServer import mark_raw
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
@@ -285,7 +286,7 @@ class SNESState(enum.IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
def launch_sni() -> None:
|
def launch_sni() -> None:
|
||||||
sni_path = Utils.get_settings()["sni_options"]["sni_path"]
|
sni_path = Settings.sni_options.sni_path
|
||||||
|
|
||||||
if not os.path.isdir(sni_path):
|
if not os.path.isdir(sni_path):
|
||||||
sni_path = Utils.local_path(sni_path)
|
sni_path = Utils.local_path(sni_path)
|
||||||
@@ -668,8 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile: str) -> None:
|
async def run_game(romfile: str) -> None:
|
||||||
auto_start = typing.cast(typing.Union[bool, str],
|
auto_start = Settings.sni_options.snes_rom_start
|
||||||
Utils.get_settings()["sni_options"].get("snes_rom_start", True))
|
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
121
Utils.py
121
Utils.py
@@ -47,7 +47,7 @@ class Version(typing.NamedTuple):
|
|||||||
return ".".join(str(item) for item in self)
|
return ".".join(str(item) for item in self)
|
||||||
|
|
||||||
|
|
||||||
__version__ = "0.6.0"
|
__version__ = "0.6.4"
|
||||||
version_tuple = tuplize_version(__version__)
|
version_tuple = tuplize_version(__version__)
|
||||||
|
|
||||||
is_linux = sys.platform.startswith("linux")
|
is_linux = sys.platform.startswith("linux")
|
||||||
@@ -114,6 +114,8 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
|
|||||||
cache[arg] = res
|
cache[arg] = res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
wrap.__defaults__ = function.__defaults__
|
||||||
|
|
||||||
return wrap
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
@@ -137,8 +139,11 @@ def local_path(*path: str) -> str:
|
|||||||
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
||||||
else:
|
else:
|
||||||
import __main__
|
import __main__
|
||||||
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
if globals().get("__file__") and os.path.isfile(__file__):
|
||||||
# we are running in a normal Python environment
|
# we are running in a normal Python environment
|
||||||
|
local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
||||||
|
# we are running in a normal Python environment, but AP was imported weirdly
|
||||||
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
||||||
else:
|
else:
|
||||||
# pray
|
# pray
|
||||||
@@ -161,6 +166,10 @@ def home_path(*path: str) -> str:
|
|||||||
os.symlink(home_path.cached_path, legacy_home_path)
|
os.symlink(home_path.cached_path, legacy_home_path)
|
||||||
else:
|
else:
|
||||||
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
||||||
|
elif sys.platform == 'darwin':
|
||||||
|
import platformdirs
|
||||||
|
home_path.cached_path = platformdirs.user_data_dir("Archipelago", False)
|
||||||
|
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
|
||||||
else:
|
else:
|
||||||
# not implemented
|
# not implemented
|
||||||
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
|
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
|
||||||
@@ -172,7 +181,7 @@ def user_path(*path: str) -> str:
|
|||||||
"""Returns either local_path or home_path based on write permissions."""
|
"""Returns either local_path or home_path based on write permissions."""
|
||||||
if hasattr(user_path, "cached_path"):
|
if hasattr(user_path, "cached_path"):
|
||||||
pass
|
pass
|
||||||
elif os.access(local_path(), os.W_OK):
|
elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()):
|
||||||
user_path.cached_path = local_path()
|
user_path.cached_path = local_path()
|
||||||
else:
|
else:
|
||||||
user_path.cached_path = home_path()
|
user_path.cached_path = home_path()
|
||||||
@@ -221,7 +230,12 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
|
|||||||
from shutil import which
|
from shutil import which
|
||||||
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
|
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
|
||||||
assert open_command, "Didn't find program for open_file! Please report this together with system details."
|
assert open_command, "Didn't find program for open_file! Please report this together with system details."
|
||||||
subprocess.call([open_command, filename])
|
|
||||||
|
env = os.environ
|
||||||
|
if "LD_LIBRARY_PATH" in env:
|
||||||
|
env = env.copy()
|
||||||
|
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||||
|
subprocess.call([open_command, filename], env=env)
|
||||||
|
|
||||||
|
|
||||||
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
|
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
|
||||||
@@ -399,13 +413,26 @@ def get_adjuster_settings(game_name: str) -> Namespace:
|
|||||||
|
|
||||||
@cache_argsless
|
@cache_argsless
|
||||||
def get_unique_identifier():
|
def get_unique_identifier():
|
||||||
uuid = persistent_load().get("client", {}).get("uuid", None)
|
common_path = cache_path("common.json")
|
||||||
|
try:
|
||||||
|
with open(common_path) as f:
|
||||||
|
common_file = json.load(f)
|
||||||
|
uuid = common_file.get("uuid", None)
|
||||||
|
except FileNotFoundError:
|
||||||
|
common_file = {}
|
||||||
|
uuid = None
|
||||||
|
|
||||||
if uuid:
|
if uuid:
|
||||||
return uuid
|
return uuid
|
||||||
|
|
||||||
import uuid
|
from uuid import uuid4
|
||||||
uuid = uuid.getnode()
|
uuid = str(uuid4())
|
||||||
persistent_store("client", "uuid", uuid)
|
common_file["uuid"] = uuid
|
||||||
|
|
||||||
|
cache_folder = os.path.dirname(common_path)
|
||||||
|
os.makedirs(cache_folder, exist_ok=True)
|
||||||
|
with open(common_path, "w") as f:
|
||||||
|
json.dump(common_file, f, separators=(",", ":"))
|
||||||
return uuid
|
return uuid
|
||||||
|
|
||||||
|
|
||||||
@@ -427,6 +454,10 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
def find_class(self, module: str, name: str) -> type:
|
def find_class(self, module: str, name: str) -> type:
|
||||||
if module == "builtins" and name in safe_builtins:
|
if module == "builtins" and name in safe_builtins:
|
||||||
return getattr(builtins, name)
|
return getattr(builtins, name)
|
||||||
|
# used by OptionCounter
|
||||||
|
# necessary because the actual Options class instances are pickled when transfered to WebHost generation pool
|
||||||
|
if module == "collections" and name == "Counter":
|
||||||
|
return collections.Counter
|
||||||
# used by MultiServer -> savegame/multidata
|
# used by MultiServer -> savegame/multidata
|
||||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
|
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
|
||||||
"SlotType", "NetworkSlot", "HintStatus"}:
|
"SlotType", "NetworkSlot", "HintStatus"}:
|
||||||
@@ -455,6 +486,18 @@ def restricted_loads(s: bytes) -> Any:
|
|||||||
return RestrictedUnpickler(io.BytesIO(s)).load()
|
return RestrictedUnpickler(io.BytesIO(s)).load()
|
||||||
|
|
||||||
|
|
||||||
|
def restricted_dumps(obj: Any) -> bytes:
|
||||||
|
"""Helper function analogous to pickle.dumps()."""
|
||||||
|
s = pickle.dumps(obj)
|
||||||
|
# Assert that the string can be successfully loaded by restricted_loads
|
||||||
|
try:
|
||||||
|
restricted_loads(s)
|
||||||
|
except pickle.UnpicklingError as e:
|
||||||
|
raise pickle.PicklingError(e) from e
|
||||||
|
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
class ByValue:
|
class ByValue:
|
||||||
"""
|
"""
|
||||||
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
|
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
|
||||||
@@ -532,6 +575,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
|
|||||||
if add_timestamp:
|
if add_timestamp:
|
||||||
stream_handler.setFormatter(formatter)
|
stream_handler.setFormatter(formatter)
|
||||||
root_logger.addHandler(stream_handler)
|
root_logger.addHandler(stream_handler)
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
# Relay unhandled exceptions to logger.
|
# Relay unhandled exceptions to logger.
|
||||||
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
|
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
|
||||||
@@ -630,6 +675,8 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
|
|||||||
import jellyfish
|
import jellyfish
|
||||||
|
|
||||||
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
||||||
|
if word1 == word2:
|
||||||
|
return 1.01
|
||||||
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
||||||
/ max(len(word1), len(word2)))
|
/ max(len(word1), len(word2)))
|
||||||
|
|
||||||
@@ -650,8 +697,10 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
|
|||||||
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
|
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
|
||||||
if len(picks) > 1:
|
if len(picks) > 1:
|
||||||
dif = picks[0][1] - picks[1][1]
|
dif = picks[0][1] - picks[1][1]
|
||||||
if picks[0][1] == 100:
|
if picks[0][1] == 101:
|
||||||
return picks[0][0], True, "Perfect Match"
|
return picks[0][0], True, "Perfect Match"
|
||||||
|
elif picks[0][1] == 100:
|
||||||
|
return picks[0][0], True, "Case Insensitive Perfect Match"
|
||||||
elif picks[0][1] < 75:
|
elif picks[0][1] < 75:
|
||||||
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
|
||||||
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
|
||||||
@@ -694,25 +743,30 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
|
|||||||
res.put(open_filename(*args))
|
res.put(open_filename(*args))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_for_stdout(*args: str):
|
||||||
|
env = os.environ
|
||||||
|
if "LD_LIBRARY_PATH" in env:
|
||||||
|
env = env.copy()
|
||||||
|
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
|
||||||
|
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
|
||||||
|
|
||||||
|
|
||||||
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
|
||||||
-> typing.Optional[str]:
|
-> typing.Optional[str]:
|
||||||
logging.info(f"Opening file input dialog for {title}.")
|
logging.info(f"Opening file input dialog for {title}.")
|
||||||
|
|
||||||
def run(*args: str):
|
|
||||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
|
||||||
|
|
||||||
if is_linux:
|
if is_linux:
|
||||||
# prefer native dialog
|
# prefer native dialog
|
||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||||
return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
|
return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
|
||||||
zenity = which("zenity")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||||
selection = (f"--filename={suggest}",) if suggest else ()
|
selection = (f"--filename={suggest}",) if suggest else ()
|
||||||
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
||||||
|
|
||||||
# fall back to tk
|
# fall back to tk
|
||||||
try:
|
try:
|
||||||
@@ -746,21 +800,18 @@ def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args
|
|||||||
|
|
||||||
|
|
||||||
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
||||||
def run(*args: str):
|
|
||||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
|
||||||
|
|
||||||
if is_linux:
|
if is_linux:
|
||||||
# prefer native dialog
|
# prefer native dialog
|
||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
return run(kdialog, f"--title={title}", "--getexistingdirectory",
|
return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory",
|
||||||
os.path.abspath(suggest) if suggest else ".")
|
os.path.abspath(suggest) if suggest else ".")
|
||||||
zenity = which("zenity")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
z_filters = ("--directory",)
|
z_filters = ("--directory",)
|
||||||
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
|
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
|
||||||
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
||||||
|
|
||||||
# fall back to tk
|
# fall back to tk
|
||||||
try:
|
try:
|
||||||
@@ -787,9 +838,6 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def messagebox(title: str, text: str, error: bool = False) -> None:
|
def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||||
def run(*args: str):
|
|
||||||
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
|
|
||||||
|
|
||||||
if is_kivy_running():
|
if is_kivy_running():
|
||||||
from kvui import MessageBox
|
from kvui import MessageBox
|
||||||
MessageBox(title, text, error).open()
|
MessageBox(title, text, error).open()
|
||||||
@@ -800,10 +848,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
|||||||
from shutil import which
|
from shutil import which
|
||||||
kdialog = which("kdialog")
|
kdialog = which("kdialog")
|
||||||
if kdialog:
|
if kdialog:
|
||||||
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
|
return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
|
||||||
zenity = which("zenity")
|
zenity = which("zenity")
|
||||||
if zenity:
|
if zenity:
|
||||||
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
|
return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
|
||||||
|
|
||||||
elif is_windows:
|
elif is_windows:
|
||||||
import ctypes
|
import ctypes
|
||||||
@@ -855,7 +903,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
|
|||||||
Use this to start a task when you don't keep a reference to it or immediately await it,
|
Use this to start a task when you don't keep a reference to it or immediately await it,
|
||||||
to prevent early garbage collection. "fire-and-forget"
|
to prevent early garbage collection. "fire-and-forget"
|
||||||
"""
|
"""
|
||||||
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
|
# https://docs.python.org/3.11/library/asyncio-task.html#asyncio.create_task
|
||||||
# Python docs:
|
# Python docs:
|
||||||
# ```
|
# ```
|
||||||
# Important: Save a reference to the result of [asyncio.create_task],
|
# Important: Save a reference to the result of [asyncio.create_task],
|
||||||
@@ -892,15 +940,15 @@ class DeprecateDict(dict):
|
|||||||
|
|
||||||
|
|
||||||
def _extend_freeze_support() -> None:
|
def _extend_freeze_support() -> None:
|
||||||
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
|
"""Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first."""
|
||||||
# upstream issue: https://github.com/python/cpython/issues/76327
|
# original upstream issue: https://github.com/python/cpython/issues/76327
|
||||||
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
|
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import multiprocessing.spawn
|
import multiprocessing.spawn
|
||||||
|
|
||||||
def _freeze_support() -> None:
|
def _freeze_support() -> None:
|
||||||
"""Minimal freeze_support. Only apply this if frozen."""
|
"""Minimal freeze_support. Only apply this if frozen."""
|
||||||
from subprocess import _args_from_interpreter_flags
|
from subprocess import _args_from_interpreter_flags # noqa
|
||||||
|
|
||||||
# Prevent `spawn` from trying to read `__main__` in from the main script
|
# Prevent `spawn` from trying to read `__main__` in from the main script
|
||||||
multiprocessing.process.ORIGINAL_DIR = None
|
multiprocessing.process.ORIGINAL_DIR = None
|
||||||
@@ -908,8 +956,7 @@ def _extend_freeze_support() -> None:
|
|||||||
# Handle the first process that MP will create
|
# Handle the first process that MP will create
|
||||||
if (
|
if (
|
||||||
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
|
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
|
||||||
'from multiprocessing.semaphore_tracker import main', # Py<3.8
|
'from multiprocessing.resource_tracker import main',
|
||||||
'from multiprocessing.resource_tracker import main', # Py>=3.8
|
|
||||||
'from multiprocessing.forkserver import main'
|
'from multiprocessing.forkserver import main'
|
||||||
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
|
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
|
||||||
):
|
):
|
||||||
@@ -928,17 +975,23 @@ def _extend_freeze_support() -> None:
|
|||||||
multiprocessing.spawn.spawn_main(**kwargs)
|
multiprocessing.spawn.spawn_main(**kwargs)
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|
||||||
if not is_windows and is_frozen():
|
def _noop() -> None:
|
||||||
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
|
pass
|
||||||
|
|
||||||
|
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
|
||||||
|
|
||||||
|
|
||||||
def freeze_support() -> None:
|
def freeze_support() -> None:
|
||||||
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
|
"""This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load."""
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
_extend_freeze_support()
|
|
||||||
|
deprecate("Use multiprocessing.freeze_support() instead")
|
||||||
multiprocessing.freeze_support()
|
multiprocessing.freeze_support()
|
||||||
|
|
||||||
|
|
||||||
|
_extend_freeze_support()
|
||||||
|
|
||||||
|
|
||||||
def visualize_regions(root_region: Region, file_name: str, *,
|
def visualize_regions(root_region: Region, file_name: str, *,
|
||||||
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
||||||
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
|
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
|
||||||
|
|||||||
50
WebHost.py
50
WebHost.py
@@ -54,16 +54,15 @@ def get_app() -> "Flask":
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
def copy_tutorials_files_to_static() -> None:
|
||||||
import json
|
|
||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
zfile: zipfile.ZipInfo
|
zfile: zipfile.ZipInfo
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
worlds = {}
|
worlds = {}
|
||||||
data = []
|
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
for game, world in AutoWorldRegister.world_types.items():
|
||||||
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
||||||
worlds[game] = world
|
worlds[game] = world
|
||||||
@@ -72,7 +71,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
|||||||
shutil.rmtree(base_target_path, ignore_errors=True)
|
shutil.rmtree(base_target_path, ignore_errors=True)
|
||||||
for game, world in worlds.items():
|
for game, world in worlds.items():
|
||||||
# copy files from world's docs folder to the generated folder
|
# copy files from world's docs folder to the generated folder
|
||||||
target_path = os.path.join(base_target_path, get_file_safe_name(game))
|
target_path = os.path.join(base_target_path, secure_filename(game))
|
||||||
os.makedirs(target_path, exist_ok=True)
|
os.makedirs(target_path, exist_ok=True)
|
||||||
|
|
||||||
if world.zip_path:
|
if world.zip_path:
|
||||||
@@ -85,45 +84,14 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
|||||||
for zfile in zf.infolist():
|
for zfile in zf.infolist():
|
||||||
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
||||||
zfile.filename = os.path.basename(zfile.filename)
|
zfile.filename = os.path.basename(zfile.filename)
|
||||||
zf.extract(zfile, target_path)
|
with open(os.path.join(target_path, secure_filename(zfile.filename)), "wb") as f:
|
||||||
|
f.write(zf.read(zfile))
|
||||||
else:
|
else:
|
||||||
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
||||||
files = os.listdir(source_path)
|
files = os.listdir(source_path)
|
||||||
for file in files:
|
for file in files:
|
||||||
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
|
shutil.copyfile(Utils.local_path(source_path, file),
|
||||||
|
Utils.local_path(target_path, secure_filename(file)))
|
||||||
# build a json tutorial dict per game
|
|
||||||
game_data = {'gameTitle': game, 'tutorials': []}
|
|
||||||
for tutorial in world.web.tutorials:
|
|
||||||
# build dict for the json file
|
|
||||||
current_tutorial = {
|
|
||||||
'name': tutorial.tutorial_name,
|
|
||||||
'description': tutorial.description,
|
|
||||||
'files': [{
|
|
||||||
'language': tutorial.language,
|
|
||||||
'filename': game + '/' + tutorial.file_name,
|
|
||||||
'link': f'{game}/{tutorial.link}',
|
|
||||||
'authors': tutorial.authors
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
# check if the name of the current guide exists already
|
|
||||||
for guide in game_data['tutorials']:
|
|
||||||
if guide and tutorial.tutorial_name == guide['name']:
|
|
||||||
guide['files'].append(current_tutorial['files'][0])
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
game_data['tutorials'].append(current_tutorial)
|
|
||||||
|
|
||||||
data.append(game_data)
|
|
||||||
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
|
|
||||||
generic_data = {}
|
|
||||||
for games in data:
|
|
||||||
if 'Archipelago' in games['gameTitle']:
|
|
||||||
generic_data = data.pop(data.index(games))
|
|
||||||
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
|
|
||||||
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
|
||||||
return sorted_data
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -131,18 +99,18 @@ if __name__ == "__main__":
|
|||||||
multiprocessing.set_start_method('spawn')
|
multiprocessing.set_start_method('spawn')
|
||||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||||
|
|
||||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
|
||||||
from WebHostLib.autolauncher import autohost, autogen, stop
|
from WebHostLib.autolauncher import autohost, autogen, stop
|
||||||
from WebHostLib.options import create as create_options_files
|
from WebHostLib.options import create as create_options_files
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||||
update_sprites_lttp()
|
update_sprites_lttp()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
logging.warning("Could not update LttP sprites.")
|
logging.warning("Could not update LttP sprites.")
|
||||||
app = get_app()
|
app = get_app()
|
||||||
create_options_files()
|
create_options_files()
|
||||||
create_ordered_tutorials_file()
|
copy_tutorials_files_to_static()
|
||||||
if app.config["SELFLAUNCH"]:
|
if app.config["SELFLAUNCH"]:
|
||||||
autohost(app.config)
|
autohost(app.config)
|
||||||
if app.config["SELFGEN"]:
|
if app.config["SELFGEN"]:
|
||||||
|
|||||||
@@ -61,32 +61,43 @@ cache = Cache()
|
|||||||
Compress(app)
|
Compress(app)
|
||||||
|
|
||||||
|
|
||||||
|
def to_python(value):
|
||||||
|
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
||||||
|
|
||||||
|
|
||||||
|
def to_url(value):
|
||||||
|
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
class B64UUIDConverter(BaseConverter):
|
class B64UUIDConverter(BaseConverter):
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
return to_python(value)
|
||||||
|
|
||||||
def to_url(self, value):
|
def to_url(self, value):
|
||||||
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
return to_url(value)
|
||||||
|
|
||||||
|
|
||||||
# short UUID
|
# short UUID
|
||||||
app.url_map.converters["suuid"] = B64UUIDConverter
|
app.url_map.converters["suuid"] = B64UUIDConverter
|
||||||
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
app.jinja_env.filters["suuid"] = to_url
|
||||||
app.jinja_env.filters["title_sorted"] = title_sorted
|
app.jinja_env.filters["title_sorted"] = title_sorted
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
"""Import submodules, triggering their registering on flask routing.
|
"""Import submodules, triggering their registering on flask routing.
|
||||||
Note: initializes worlds subsystem."""
|
Note: initializes worlds subsystem."""
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from werkzeug.utils import find_modules
|
||||||
# has automatic patch integration
|
# has automatic patch integration
|
||||||
import worlds.AutoWorld
|
|
||||||
import worlds.Files
|
import worlds.Files
|
||||||
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
|
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
|
||||||
game_name in worlds.Files.AutoPatchRegister.patch_types
|
|
||||||
|
|
||||||
from WebHostLib.customserver import run_server_process
|
from WebHostLib.customserver import run_server_process
|
||||||
# to trigger app routing picking up on it
|
|
||||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session
|
|
||||||
|
|
||||||
|
for module in find_modules("WebHostLib", include_packages=True):
|
||||||
|
importlib.import_module(module)
|
||||||
|
|
||||||
|
from . import api
|
||||||
app.register_blueprint(api.api_endpoints)
|
app.register_blueprint(api.api_endpoints)
|
||||||
|
|||||||
@@ -11,5 +11,5 @@ api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
|||||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||||
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
|
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
|
||||||
|
|
||||||
|
# trigger endpoint registration
|
||||||
from . import datapackage, generate, room, user # trigger registration
|
from . import datapackage, generate, room, tracker, user
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
import pickle
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from flask import request, session, url_for
|
from flask import request, session, url_for
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from pony.orm import commit
|
from pony.orm import commit
|
||||||
|
|
||||||
|
from Utils import restricted_dumps
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from WebHostLib.check import get_yaml_data, roll_options
|
from WebHostLib.check import get_yaml_data, roll_options
|
||||||
from WebHostLib.generate import get_meta
|
from WebHostLib.generate import get_meta
|
||||||
@@ -56,7 +56,7 @@ def generate_api():
|
|||||||
"detail": results}, 400
|
"detail": results}, 400
|
||||||
else:
|
else:
|
||||||
gen = Generation(
|
gen = Generation(
|
||||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||||
# convert to json compatible
|
# convert to json compatible
|
||||||
meta=json.dumps(meta), state=STATE_QUEUED,
|
meta=json.dumps(meta), state=STATE_QUEUED,
|
||||||
owner=session["_id"])
|
owner=session["_id"])
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from flask import abort, url_for
|
from flask import abort, url_for
|
||||||
|
|
||||||
|
from WebHostLib import to_url
|
||||||
import worlds.Files
|
import worlds.Files
|
||||||
from . import api_endpoints, get_players
|
from . import api_endpoints, get_players
|
||||||
from ..models import Room
|
from ..models import Room
|
||||||
@@ -33,7 +34,7 @@ def room_info(room_id: UUID) -> Dict[str, Any]:
|
|||||||
downloads.append(slot_download)
|
downloads.append(slot_download)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tracker": room.tracker,
|
"tracker": to_url(room.tracker),
|
||||||
"players": get_players(room.seed),
|
"players": get_players(room.seed),
|
||||||
"last_port": room.last_port,
|
"last_port": room.last_port,
|
||||||
"last_activity": room.last_activity,
|
"last_activity": room.last_activity,
|
||||||
|
|||||||
232
WebHostLib/api/tracker.py
Normal file
232
WebHostLib/api/tracker.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
||||||
|
class PlayerAlias(TypedDict):
|
||||||
|
player: int
|
||||||
|
name: str | None
|
||||||
|
|
||||||
|
player_aliases: list[dict[str, int | list[PlayerAlias]]] = []
|
||||||
|
"""Slot aliases of all players."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
team_player_aliases: list[PlayerAlias] = []
|
||||||
|
team_aliases = {"team": team, "players": team_player_aliases}
|
||||||
|
player_aliases.append(team_aliases)
|
||||||
|
for player in players:
|
||||||
|
team_player_aliases.append({"player": player, "alias": tracker_data.get_player_alias(team, player)})
|
||||||
|
|
||||||
|
class PlayerItemsReceived(TypedDict):
|
||||||
|
player: int
|
||||||
|
items: list[NetworkItem]
|
||||||
|
|
||||||
|
player_items_received: list[dict[str, int | list[PlayerItemsReceived]]] = []
|
||||||
|
"""Items received by each player."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
player_received_items: list[PlayerItemsReceived] = []
|
||||||
|
team_items_received = {"team": team, "players": player_received_items}
|
||||||
|
player_items_received.append(team_items_received)
|
||||||
|
for player in players:
|
||||||
|
player_received_items.append(
|
||||||
|
{"player": player, "items": tracker_data.get_player_received_items(team, player)})
|
||||||
|
|
||||||
|
class PlayerChecksDone(TypedDict):
|
||||||
|
player: int
|
||||||
|
locations: list[int]
|
||||||
|
|
||||||
|
player_checks_done: list[dict[str, int | list[PlayerChecksDone]]] = []
|
||||||
|
"""ID of all locations checked by each player."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
per_player_checks: list[PlayerChecksDone] = []
|
||||||
|
team_checks_done = {"team": team, "players": per_player_checks}
|
||||||
|
player_checks_done.append(team_checks_done)
|
||||||
|
for player in players:
|
||||||
|
per_player_checks.append(
|
||||||
|
{"player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
|
||||||
|
|
||||||
|
total_checks_done: list[dict[str, int]] = [
|
||||||
|
{"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."""
|
||||||
|
|
||||||
|
class PlayerHints(TypedDict):
|
||||||
|
player: int
|
||||||
|
hints: list[Hint]
|
||||||
|
|
||||||
|
hints: list[dict[str, int | list[PlayerHints]]] = []
|
||||||
|
"""Hints that all players have used or received."""
|
||||||
|
for team, players in tracker_data.get_all_slots().items():
|
||||||
|
per_player_hints: list[PlayerHints] = []
|
||||||
|
team_hints = {"team": team, "players": per_player_hints}
|
||||||
|
hints.append(team_hints)
|
||||||
|
for player in players:
|
||||||
|
player_hints = sorted(tracker_data.get_player_hints(team, player))
|
||||||
|
per_player_hints.append({"player": player, "hints": player_hints})
|
||||||
|
slot_info = tracker_data.get_slot_info(team, player)
|
||||||
|
# this assumes groups are always after players
|
||||||
|
if slot_info.type != SlotType.group:
|
||||||
|
continue
|
||||||
|
for member in slot_info.group_members:
|
||||||
|
team_hints[member]["hints"] += player_hints
|
||||||
|
|
||||||
|
class PlayerTimer(TypedDict):
|
||||||
|
player: int
|
||||||
|
time: datetime | None
|
||||||
|
|
||||||
|
activity_timers: list[dict[str, int | 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():
|
||||||
|
player_timers: list[PlayerTimer] = []
|
||||||
|
team_timers = {"team": team, "players": player_timers}
|
||||||
|
activity_timers.append(team_timers)
|
||||||
|
for player in players:
|
||||||
|
player_timers.append({"player": player, "time": None})
|
||||||
|
|
||||||
|
client_activity_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get("client_activity_timers", ())
|
||||||
|
for (team, player), timestamp in client_activity_timers:
|
||||||
|
# use index since we can rely on order
|
||||||
|
# FIX: key is "players" (not "player_timers")
|
||||||
|
activity_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
connection_timers: list[dict[str, int | 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():
|
||||||
|
player_timers: list[PlayerTimer] = []
|
||||||
|
team_connection_timers = {"team": team, "players": player_timers}
|
||||||
|
connection_timers.append(team_connection_timers)
|
||||||
|
for player in players:
|
||||||
|
player_timers.append({"player": player, "time": None})
|
||||||
|
|
||||||
|
client_connection_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get(
|
||||||
|
"client_connection_timers", ())
|
||||||
|
for (team, player), timestamp in client_connection_timers:
|
||||||
|
connection_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||||
|
|
||||||
|
class PlayerStatus(TypedDict):
|
||||||
|
player: int
|
||||||
|
status: ClientStatus
|
||||||
|
|
||||||
|
player_status: list[dict[str, int | list[PlayerStatus]]] = []
|
||||||
|
"""The current client status for each player."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
player_statuses: list[PlayerStatus] = []
|
||||||
|
team_status = {"team": team, "players": player_statuses}
|
||||||
|
player_status.append(team_status)
|
||||||
|
for player in players:
|
||||||
|
player_statuses.append({"player": player, "status": tracker_data.get_player_client_status(team, player)})
|
||||||
|
|
||||||
|
return {
|
||||||
|
**get_static_tracker_data(room),
|
||||||
|
"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,
|
||||||
|
"datapackage": tracker_data._multidata["datapackage"],
|
||||||
|
}
|
||||||
|
|
||||||
|
@cache.memoize()
|
||||||
|
def get_static_tracker_data(room: Room) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Builds and caches the static data for this active session tracker, so that it doesn't need to be recalculated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tracker_data = TrackerData(room)
|
||||||
|
|
||||||
|
all_players: dict[int, list[int]] = tracker_data.get_all_players()
|
||||||
|
|
||||||
|
class PlayerGroups(TypedDict):
|
||||||
|
slot: int
|
||||||
|
name: str
|
||||||
|
members: list[int]
|
||||||
|
|
||||||
|
groups: list[dict[str, int | 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():
|
||||||
|
groups_in_team: list[PlayerGroups] = []
|
||||||
|
team_groups = {"team": team, "groups": groups_in_team}
|
||||||
|
groups.append(team_groups)
|
||||||
|
for player in players:
|
||||||
|
slot_info = tracker_data.get_slot_info(team, player)
|
||||||
|
if slot_info.type != SlotType.group or not slot_info.group_members:
|
||||||
|
continue
|
||||||
|
groups_in_team.append(
|
||||||
|
{
|
||||||
|
"slot": player,
|
||||||
|
"name": slot_info.name,
|
||||||
|
"members": list(slot_info.group_members),
|
||||||
|
})
|
||||||
|
class PlayerName(TypedDict):
|
||||||
|
player: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
player_names: list[dict[str, str | list[PlayerName]]] = []
|
||||||
|
"""Slot names of all players."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
per_team_player_names: list[PlayerName] = []
|
||||||
|
team_names = {"team": team, "players": per_team_player_names}
|
||||||
|
player_names.append(team_names)
|
||||||
|
for player in players:
|
||||||
|
per_team_player_names.append({"player": player, "name": tracker_data.get_player_name(team, player)})
|
||||||
|
|
||||||
|
class PlayerGame(TypedDict):
|
||||||
|
player: int
|
||||||
|
game: str
|
||||||
|
|
||||||
|
games: list[dict[str, int | list[PlayerGame]]] = []
|
||||||
|
"""The game each player is playing."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
player_games: list[PlayerGame] = []
|
||||||
|
team_games = {"team": team, "players": player_games}
|
||||||
|
games.append(team_games)
|
||||||
|
for player in players:
|
||||||
|
player_games.append({"player": player, "game": tracker_data.get_player_game(team, player)})
|
||||||
|
|
||||||
|
class PlayerSlotData(TypedDict):
|
||||||
|
player: int
|
||||||
|
slot_data: dict[str, Any]
|
||||||
|
|
||||||
|
slot_data: list[dict[str, int | list[PlayerSlotData]]] = []
|
||||||
|
"""Slot data for each player."""
|
||||||
|
for team, players in all_players.items():
|
||||||
|
player_slot_data: list[PlayerSlotData] = []
|
||||||
|
team_slot_data = {"team": team, "players": player_slot_data}
|
||||||
|
slot_data.append(team_slot_data)
|
||||||
|
for player in players:
|
||||||
|
player_slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(team, player)})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"groups": groups,
|
||||||
|
"slot_data": slot_data,
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import session, jsonify
|
from flask import session, jsonify
|
||||||
from pony.orm import select
|
from pony.orm import select
|
||||||
|
|
||||||
|
from WebHostLib import to_url
|
||||||
from WebHostLib.models import Room, Seed
|
from WebHostLib.models import Room, Seed
|
||||||
from . import api_endpoints, get_players
|
from . import api_endpoints, get_players
|
||||||
|
|
||||||
@@ -10,13 +11,13 @@ def get_rooms():
|
|||||||
response = []
|
response = []
|
||||||
for room in select(room for room in Room if room.owner == session["_id"]):
|
for room in select(room for room in Room if room.owner == session["_id"]):
|
||||||
response.append({
|
response.append({
|
||||||
"room_id": room.id,
|
"room_id": to_url(room.id),
|
||||||
"seed_id": room.seed.id,
|
"seed_id": to_url(room.seed.id),
|
||||||
"creation_time": room.creation_time,
|
"creation_time": room.creation_time,
|
||||||
"last_activity": room.last_activity,
|
"last_activity": room.last_activity,
|
||||||
"last_port": room.last_port,
|
"last_port": room.last_port,
|
||||||
"timeout": room.timeout,
|
"timeout": room.timeout,
|
||||||
"tracker": room.tracker,
|
"tracker": to_url(room.tracker),
|
||||||
})
|
})
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|
||||||
@@ -26,8 +27,8 @@ def get_seeds():
|
|||||||
response = []
|
response = []
|
||||||
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
|
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
|
||||||
response.append({
|
response.append({
|
||||||
"seed_id": seed.id,
|
"seed_id": to_url(seed.id),
|
||||||
"creation_time": seed.creation_time,
|
"creation_time": seed.creation_time,
|
||||||
"players": get_players(seed.slots),
|
"players": get_players(seed),
|
||||||
})
|
})
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from threading import Event, Thread
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pony.orm import db_session, select, commit
|
from pony.orm import db_session, select, commit, PrimaryKey
|
||||||
|
|
||||||
from Utils import restricted_loads
|
from Utils import restricted_loads
|
||||||
from .locker import Locker, AlreadyRunningException
|
from .locker import Locker, AlreadyRunningException
|
||||||
@@ -36,12 +36,21 @@ def handle_generation_failure(result: BaseException):
|
|||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
|
setproctitle(f"Generator ({sid})")
|
||||||
|
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
|
||||||
|
setproctitle(f"Generator (idle)")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||||
try:
|
try:
|
||||||
meta = json.loads(generation.meta)
|
meta = json.loads(generation.meta)
|
||||||
options = restricted_loads(generation.options)
|
options = restricted_loads(generation.options)
|
||||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||||
pool.apply_async(gen_game, (options,),
|
pool.apply_async(_mp_gen_game, (options,),
|
||||||
{"meta": meta,
|
{"meta": meta,
|
||||||
"sid": generation.id,
|
"sid": generation.id,
|
||||||
"owner": generation.owner},
|
"owner": generation.owner},
|
||||||
@@ -55,6 +64,10 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
|||||||
|
|
||||||
|
|
||||||
def init_generator(config: dict[str, Any]) -> None:
|
def init_generator(config: dict[str, Any]) -> None:
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
|
setproctitle("Generator (idle)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import resource
|
import resource
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
@@ -151,9 +164,6 @@ def autogen(config: dict):
|
|||||||
Thread(target=keep_running, name="AP_Autogen").start()
|
Thread(target=keep_running, name="AP_Autogen").start()
|
||||||
|
|
||||||
|
|
||||||
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class MultiworldInstance():
|
class MultiworldInstance():
|
||||||
def __init__(self, config: dict, id: int):
|
def __init__(self, config: dict, id: int):
|
||||||
self.room_ids = set()
|
self.room_ids = set()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
import base64
|
import base64
|
||||||
from typing import Union, Dict, Set, Tuple
|
from collections.abc import Set
|
||||||
|
|
||||||
from flask import request, flash, redirect, url_for, render_template
|
from flask import request, flash, redirect, url_for, render_template
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
@@ -43,7 +43,7 @@ def mysterycheck():
|
|||||||
return redirect(url_for("check"), 301)
|
return redirect(url_for("check"), 301)
|
||||||
|
|
||||||
|
|
||||||
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
|
def get_yaml_data(files) -> dict[str, str] | str | Markup:
|
||||||
options = {}
|
options = {}
|
||||||
for uploaded_file in files:
|
for uploaded_file in files:
|
||||||
if banned_file(uploaded_file.filename):
|
if banned_file(uploaded_file.filename):
|
||||||
@@ -84,12 +84,12 @@ def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
def roll_options(options: Dict[str, Union[dict, str]],
|
def roll_options(options: dict[str, dict | str],
|
||||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
tuple[dict[str, str | bool], dict[str, dict]]:
|
||||||
plando_options = PlandoOptions.from_set(set(plando_options))
|
plando_options = PlandoOptions.from_set(set(plando_options))
|
||||||
results = {}
|
results: dict[str, str | bool] = {}
|
||||||
rolled_results = {}
|
rolled_results: dict[str, dict] = {}
|
||||||
for filename, text in options.items():
|
for filename, text in options.items():
|
||||||
try:
|
try:
|
||||||
if type(text) is dict:
|
if type(text) is dict:
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class WebHostContext(Context):
|
|||||||
else:
|
else:
|
||||||
row = GameDataPackage.get(checksum=game_data["checksum"])
|
row = GameDataPackage.get(checksum=game_data["checksum"])
|
||||||
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
||||||
game_data_packages[game] = Utils.restricted_loads(row.data)
|
game_data_packages[game] = restricted_loads(row.data)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
||||||
@@ -159,6 +159,7 @@ class WebHostContext(Context):
|
|||||||
@db_session
|
@db_session
|
||||||
def _save(self, exit_save: bool = False) -> bool:
|
def _save(self, exit_save: bool = False) -> bool:
|
||||||
room = Room.get(id=self.room_id)
|
room = Room.get(id=self.room_id)
|
||||||
|
# Does not use Utils.restricted_dumps because we'd rather make a save than not make one
|
||||||
room.multisave = pickle.dumps(self.get_save())
|
room.multisave = pickle.dumps(self.get_save())
|
||||||
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
||||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||||
@@ -227,6 +228,9 @@ def set_up_logging(room_id) -> logging.Logger:
|
|||||||
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||||
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
|
setproctitle(name)
|
||||||
Utils.init_logging(name)
|
Utils.init_logging(name)
|
||||||
try:
|
try:
|
||||||
import resource
|
import resource
|
||||||
@@ -247,8 +251,23 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
raise Exception("Worlds system should not be loaded in the custom server.")
|
raise Exception("Worlds system should not be loaded in the custom server.")
|
||||||
|
|
||||||
import gc
|
import gc
|
||||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
|
||||||
del cert_file, cert_key_file, ponyconfig
|
if not cert_file:
|
||||||
|
def get_ssl_context():
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
load_date = None
|
||||||
|
ssl_context = load_server_cert(cert_file, cert_key_file)
|
||||||
|
|
||||||
|
def get_ssl_context():
|
||||||
|
nonlocal load_date, ssl_context
|
||||||
|
today = datetime.date.today()
|
||||||
|
if load_date != today:
|
||||||
|
ssl_context = load_server_cert(cert_file, cert_key_file)
|
||||||
|
load_date = today
|
||||||
|
return ssl_context
|
||||||
|
|
||||||
|
del ponyconfig
|
||||||
gc.collect() # free intermediate objects used during setup
|
gc.collect() # free intermediate objects used during setup
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
@@ -263,12 +282,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
|||||||
assert ctx.server is None
|
assert ctx.server is None
|
||||||
try:
|
try:
|
||||||
ctx.server = websockets.serve(
|
ctx.server = websockets.serve(
|
||||||
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context())
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
except OSError: # likely port in use
|
except OSError: # likely port in use
|
||||||
ctx.server = websockets.serve(
|
ctx.server = websockets.serve(
|
||||||
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
|
||||||
|
|
||||||
await ctx.server
|
await ctx.server
|
||||||
port = 0
|
port = 0
|
||||||
|
|||||||
@@ -61,12 +61,7 @@ def download_slot_file(room_id, player_id: int):
|
|||||||
else:
|
else:
|
||||||
import io
|
import io
|
||||||
|
|
||||||
if slot_data.game == "Minecraft":
|
if slot_data.game == "Factorio":
|
||||||
from worlds.minecraft import mc_update_output
|
|
||||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
|
||||||
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
|
|
||||||
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
|
|
||||||
elif slot_data.game == "Factorio":
|
|
||||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||||
for name in zf.namelist():
|
for name in zf.namelist():
|
||||||
if name.endswith("info.json"):
|
if name.endswith("info.json"):
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pickle
|
|
||||||
import random
|
import random
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Any, Dict, List, Optional, Union, Set
|
from pickle import PicklingError
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from flask import flash, redirect, render_template, request, session, url_for
|
from flask import flash, redirect, render_template, request, session, url_for
|
||||||
from pony.orm import commit, db_session
|
from pony.orm import commit, db_session
|
||||||
|
|
||||||
from BaseClasses import get_seed, seeddigits
|
from BaseClasses import get_seed, seeddigits
|
||||||
from Generate import PlandoOptions, handle_name
|
from Generate import PlandoOptions, handle_name, mystery_argparse
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from Utils import __version__
|
from Utils import __version__, restricted_dumps
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from settings import ServerOptions, GeneratorOptions
|
from settings import ServerOptions, GeneratorOptions
|
||||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
|
||||||
from .check import get_yaml_data, roll_options
|
from .check import get_yaml_data, roll_options
|
||||||
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
||||||
from .upload import upload_zip_to_db
|
from .upload import upload_zip_to_db
|
||||||
|
|
||||||
|
|
||||||
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
|
def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | dict[str, Any]]:
|
||||||
plando_options: Set[str] = set()
|
plando_options: set[str] = set()
|
||||||
for substr in ("bosses", "items", "connections", "texts"):
|
for substr in ("bosses", "items", "connections", "texts"):
|
||||||
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
|
if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
|
||||||
plando_options.add(substr)
|
plando_options.add(substr)
|
||||||
@@ -73,7 +72,7 @@ def generate(race=False):
|
|||||||
return render_template("generate.html", race=race, version=__version__)
|
return render_template("generate.html", race=race, version=__version__)
|
||||||
|
|
||||||
|
|
||||||
def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
|
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
||||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||||
|
|
||||||
if any(type(result) == str for result in results.values()):
|
if any(type(result) == str for result in results.values()):
|
||||||
@@ -83,12 +82,18 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any])
|
|||||||
f"If you have a larger group, please generate it yourself and upload it.")
|
f"If you have a larger group, please generate it yourself and upload it.")
|
||||||
return redirect(url_for(request.endpoint, **(request.view_args or {})))
|
return redirect(url_for(request.endpoint, **(request.view_args or {})))
|
||||||
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
|
||||||
gen = Generation(
|
try:
|
||||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
gen = Generation(
|
||||||
# convert to json compatible
|
options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||||
meta=json.dumps(meta),
|
# convert to json compatible
|
||||||
state=STATE_QUEUED,
|
meta=json.dumps(meta),
|
||||||
owner=session["_id"])
|
state=STATE_QUEUED,
|
||||||
|
owner=session["_id"])
|
||||||
|
except PicklingError as e:
|
||||||
|
from .autolauncher import handle_generation_failure
|
||||||
|
handle_generation_failure(e)
|
||||||
|
return render_template("seedError.html", seed_error=("PicklingError: " + str(e)))
|
||||||
|
|
||||||
commit()
|
commit()
|
||||||
|
|
||||||
return redirect(url_for("wait_seed", seed=gen.id))
|
return redirect(url_for("wait_seed", seed=gen.id))
|
||||||
@@ -104,9 +109,9 @@ def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any])
|
|||||||
return redirect(url_for("view_seed", seed=seed_id))
|
return redirect(url_for("view_seed", seed=seed_id))
|
||||||
|
|
||||||
|
|
||||||
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None):
|
||||||
if not meta:
|
if meta is None:
|
||||||
meta: Dict[str, Any] = {}
|
meta = {}
|
||||||
|
|
||||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||||
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
||||||
@@ -123,35 +128,39 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
|||||||
|
|
||||||
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
||||||
|
|
||||||
erargs = parse_arguments(['--multi', str(playercount)])
|
args = mystery_argparse()
|
||||||
erargs.seed = seed
|
args.multi = playercount
|
||||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
args.seed = seed
|
||||||
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
|
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||||
erargs.race = race
|
args.spoiler = meta["generator_options"].get("spoiler", 0)
|
||||||
erargs.outputname = seedname
|
args.race = race
|
||||||
erargs.outputpath = target.name
|
args.outputname = seedname
|
||||||
erargs.teams = 1
|
args.outputpath = target.name
|
||||||
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
args.teams = 1
|
||||||
{"bosses", "items", "connections", "texts"}))
|
args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||||
erargs.skip_prog_balancing = False
|
{"bosses", "items", "connections", "texts"}))
|
||||||
erargs.skip_output = False
|
args.skip_prog_balancing = False
|
||||||
erargs.csv_output = False
|
args.skip_output = False
|
||||||
|
args.spoiler_only = False
|
||||||
|
args.csv_output = False
|
||||||
|
args.sprite = dict.fromkeys(range(1, args.multi+1), None)
|
||||||
|
args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
|
||||||
|
|
||||||
name_counter = Counter()
|
name_counter = Counter()
|
||||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||||
for k, v in settings.items():
|
for k, v in settings.items():
|
||||||
if v is not None:
|
if v is not None:
|
||||||
if hasattr(erargs, k):
|
if hasattr(args, k):
|
||||||
getattr(erargs, k)[player] = v
|
getattr(args, k)[player] = v
|
||||||
else:
|
else:
|
||||||
setattr(erargs, k, {player: v})
|
setattr(args, k, {player: v})
|
||||||
|
|
||||||
if not erargs.name[player]:
|
if not args.name[player]:
|
||||||
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
||||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
args.name[player] = handle_name(args.name[player], player, name_counter)
|
||||||
if len(set(erargs.name.values())) != len(erargs.name):
|
if len(set(args.name.values())) != len(args.name):
|
||||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}")
|
||||||
ERmain(erargs, seed, baked_server_options=meta["server_options"])
|
ERmain(args, seed, baked_server_options=meta["server_options"])
|
||||||
|
|
||||||
return upload_to_db(target.name, sid, owner, race)
|
return upload_to_db(target.name, sid, owner, race)
|
||||||
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import threading
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from Utils import local_path, user_path
|
from Utils import local_path, user_path
|
||||||
from worlds.alttp.Rom import Sprite
|
|
||||||
|
|
||||||
|
|
||||||
def update_sprites_lttp():
|
def update_sprites_lttp():
|
||||||
|
from worlds.alttp.Rom import Sprite
|
||||||
from tkinter import Tk
|
from tkinter import Tk
|
||||||
from LttPAdjuster import get_image_for_sprite
|
from LttPAdjuster import get_image_for_sprite
|
||||||
from LttPAdjuster import BackgroundTaskProgress
|
from LttPAdjuster import BackgroundTaskProgress
|
||||||
@@ -14,7 +14,7 @@ def update_sprites_lttp():
|
|||||||
from LttPAdjuster import update_sprites
|
from LttPAdjuster import update_sprites
|
||||||
|
|
||||||
# Target directories
|
# Target directories
|
||||||
input_dir = user_path("data", "sprites", "alttpr")
|
input_dir = user_path("data", "sprites", "alttp", "remote")
|
||||||
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
||||||
|
|
||||||
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
||||||
|
|||||||
@@ -7,17 +7,69 @@ from flask import request, redirect, url_for, render_template, Response, session
|
|||||||
from pony.orm import count, commit, db_session
|
from pony.orm import count, commit, db_session
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister, World
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
from .models import Seed, Room, Command, UUID, uuid4
|
from .models import Seed, Room, Command, UUID, uuid4
|
||||||
|
from Utils import title_sorted
|
||||||
|
|
||||||
|
|
||||||
def get_world_theme(game_name: str):
|
def get_world_theme(game_name: str) -> str:
|
||||||
if game_name in AutoWorldRegister.world_types:
|
if game_name in AutoWorldRegister.world_types:
|
||||||
return AutoWorldRegister.world_types[game_name].web.theme
|
return AutoWorldRegister.world_types[game_name].web.theme
|
||||||
return 'grass'
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown(path: str) -> str:
|
||||||
|
import mistune
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
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
|
||||||
|
import re # 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)
|
||||||
|
|
||||||
|
with open(path, encoding="utf-8-sig") as f:
|
||||||
|
document = f.read()
|
||||||
|
return markdown(document)
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
||||||
def page_not_found(err):
|
def page_not_found(err):
|
||||||
@@ -31,71 +83,103 @@ def start_playing():
|
|||||||
return render_template(f"startPlaying.html")
|
return render_template(f"startPlaying.html")
|
||||||
|
|
||||||
|
|
||||||
# Game Info Pages
|
|
||||||
@app.route('/games/<string:game>/info/<string:lang>')
|
@app.route('/games/<string:game>/info/<string:lang>')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def game_info(game, lang):
|
def game_info(game, lang):
|
||||||
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
"""Game Info Pages"""
|
||||||
|
try:
|
||||||
|
theme = get_world_theme(game)
|
||||||
|
secure_game_name = secure_filename(game)
|
||||||
|
lang = secure_filename(lang)
|
||||||
|
document = render_markdown(os.path.join(
|
||||||
|
app.static_folder, "generated", "docs",
|
||||||
|
secure_game_name, f"{lang}_{secure_game_name}.md"
|
||||||
|
))
|
||||||
|
return render_template(
|
||||||
|
"markdown_document.html",
|
||||||
|
title=f"{game} Guide",
|
||||||
|
html_from_markdown=document,
|
||||||
|
theme=theme,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
# List of supported games
|
|
||||||
@app.route('/games')
|
@app.route('/games')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def games():
|
def games():
|
||||||
worlds = {}
|
"""List of supported games"""
|
||||||
for game, world in AutoWorldRegister.world_types.items():
|
return render_template("supportedGames.html", worlds=get_visible_worlds())
|
||||||
if not world.hidden:
|
|
||||||
worlds[game] = world
|
|
||||||
return render_template("supportedGames.html", worlds=worlds)
|
@app.route('/tutorial/<string:game>/<string:file>')
|
||||||
|
@cache.cached()
|
||||||
|
def tutorial(game: str, file: str):
|
||||||
|
try:
|
||||||
|
theme = get_world_theme(game)
|
||||||
|
secure_game_name = secure_filename(game)
|
||||||
|
file = secure_filename(file)
|
||||||
|
document = render_markdown(os.path.join(
|
||||||
|
app.static_folder, "generated", "docs",
|
||||||
|
secure_game_name, file+".md"
|
||||||
|
))
|
||||||
|
return render_template(
|
||||||
|
"markdown_document.html",
|
||||||
|
title=f"{game} Guide",
|
||||||
|
html_from_markdown=document,
|
||||||
|
theme=theme,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||||
@cache.cached()
|
def tutorial_redirect(game: str, file: str, lang: str):
|
||||||
def tutorial(game, file, lang):
|
"""
|
||||||
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
Permanent redirect old tutorial URLs to new ones to keep search engines happy.
|
||||||
|
e.g. /tutorial/Archipelago/setup/en -> /tutorial/Archipelago/setup_en
|
||||||
|
"""
|
||||||
|
return redirect(url_for("tutorial", game=game, file=f"{file}_{lang}"), code=301)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/tutorial/')
|
@app.route('/tutorial/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def tutorial_landing():
|
def tutorial_landing():
|
||||||
return render_template("tutorialLanding.html")
|
tutorials = {}
|
||||||
|
worlds = AutoWorldRegister.world_types
|
||||||
|
for world_name, world_type in worlds.items():
|
||||||
|
current_world = tutorials[world_name] = {}
|
||||||
|
for tutorial in world_type.web.tutorials:
|
||||||
|
current_tutorial = current_world.setdefault(tutorial.tutorial_name, {
|
||||||
|
"description": tutorial.description, "files": {}})
|
||||||
|
current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = {
|
||||||
|
"authors": tutorial.authors,
|
||||||
|
"language": tutorial.language
|
||||||
|
}
|
||||||
|
tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
|
||||||
|
tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)}
|
||||||
|
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/faq/<string:lang>/')
|
@app.route('/faq/<string:lang>/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def faq(lang: str):
|
def faq(lang: str):
|
||||||
import markdown
|
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md"))
|
||||||
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
|
|
||||||
document = f.read()
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"markdown_document.html",
|
"markdown_document.html",
|
||||||
title="Frequently Asked Questions",
|
title="Frequently Asked Questions",
|
||||||
html_from_markdown=markdown.markdown(
|
html_from_markdown=document,
|
||||||
document,
|
|
||||||
extensions=["toc", "mdx_breakless_lists"],
|
|
||||||
extension_configs={
|
|
||||||
"toc": {"anchorlink": True}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/glossary/<string:lang>/')
|
@app.route('/glossary/<string:lang>/')
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def glossary(lang: str):
|
def glossary(lang: str):
|
||||||
import markdown
|
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md"))
|
||||||
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
|
|
||||||
document = f.read()
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"markdown_document.html",
|
"markdown_document.html",
|
||||||
title="Glossary",
|
title="Glossary",
|
||||||
html_from_markdown=markdown.markdown(
|
html_from_markdown=document,
|
||||||
document,
|
|
||||||
extensions=["toc", "mdx_breakless_lists"],
|
|
||||||
extension_configs={
|
|
||||||
"toc": {"anchorlink": True}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Dict, Union
|
|||||||
from docutils.core import publish_parts
|
from docutils.core import publish_parts
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from flask import redirect, render_template, request, Response
|
from flask import redirect, render_template, request, Response, abort
|
||||||
|
|
||||||
import Options
|
import Options
|
||||||
from Utils import local_path
|
from Utils import local_path
|
||||||
@@ -108,7 +108,7 @@ def option_presets(game: str) -> Response:
|
|||||||
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
|
||||||
|
|
||||||
presets[preset_name][preset_option_name] = option.value
|
presets[preset_name][preset_option_name] = option.value
|
||||||
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
|
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.OptionCounter)):
|
||||||
presets[preset_name][preset_option_name] = option.value
|
presets[preset_name][preset_option_name] = option.value
|
||||||
elif isinstance(preset_option, str):
|
elif isinstance(preset_option, str):
|
||||||
# Ensure the option value is valid for Choice and Toggle options
|
# Ensure the option value is valid for Choice and Toggle options
|
||||||
@@ -142,7 +142,10 @@ def weighted_options_old():
|
|||||||
@app.route("/games/<string:game>/weighted-options")
|
@app.route("/games/<string:game>/weighted-options")
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def weighted_options(game: str):
|
def weighted_options(game: str):
|
||||||
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
try:
|
||||||
|
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
|
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
|
||||||
@@ -152,7 +155,9 @@ def generate_weighted_yaml(game: str):
|
|||||||
options = {}
|
options = {}
|
||||||
|
|
||||||
for key, val in request.form.items():
|
for key, val in request.form.items():
|
||||||
if "||" not in key:
|
if val == "_ensure-empty-list":
|
||||||
|
options[key] = {}
|
||||||
|
elif "||" not in key:
|
||||||
if len(str(val)) == 0:
|
if len(str(val)) == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -197,7 +202,10 @@ def generate_weighted_yaml(game: str):
|
|||||||
@app.route("/games/<string:game>/player-options")
|
@app.route("/games/<string:game>/player-options")
|
||||||
@cache.cached()
|
@cache.cached()
|
||||||
def player_options(game: str):
|
def player_options(game: str):
|
||||||
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
try:
|
||||||
|
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
|
||||||
|
except KeyError:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
|
|
||||||
# YAML generator for player-options
|
# YAML generator for player-options
|
||||||
@@ -206,8 +214,11 @@ def generate_yaml(game: str):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
options = {}
|
options = {}
|
||||||
intent_generate = False
|
intent_generate = False
|
||||||
|
|
||||||
for key, val in request.form.items(multi=True):
|
for key, val in request.form.items(multi=True):
|
||||||
if key in options:
|
if val == "_ensure-empty-list":
|
||||||
|
options[key] = []
|
||||||
|
elif options.get(key):
|
||||||
if not isinstance(options[key], list):
|
if not isinstance(options[key], list):
|
||||||
options[key] = [options[key]]
|
options[key] = [options[key]]
|
||||||
options[key].append(val)
|
options[key].append(val)
|
||||||
@@ -216,7 +227,7 @@ def generate_yaml(game: str):
|
|||||||
|
|
||||||
for key, val in options.copy().items():
|
for key, val in options.copy().items():
|
||||||
key_parts = key.rsplit("||", 2)
|
key_parts = key.rsplit("||", 2)
|
||||||
# Detect and build ItemDict options from their name pattern
|
# Detect and build OptionCounter options from their name pattern
|
||||||
if key_parts[-1] == "qty":
|
if key_parts[-1] == "qty":
|
||||||
if key_parts[0] not in options:
|
if key_parts[0] not in options:
|
||||||
options[key_parts[0]] = {}
|
options[key_parts[0]] = {}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
flask>=3.0.3
|
flask>=3.1.1
|
||||||
werkzeug>=3.0.6
|
werkzeug>=3.1.3
|
||||||
pony>=0.7.19
|
pony>=0.7.19; python_version <= '3.12'
|
||||||
waitress>=3.0.0
|
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
|
||||||
|
waitress>=3.0.2
|
||||||
Flask-Caching>=2.3.0
|
Flask-Caching>=2.3.0
|
||||||
Flask-Compress>=1.15
|
Flask-Compress>=1.17
|
||||||
Flask-Limiter>=3.8.0
|
Flask-Limiter>=3.12
|
||||||
bokeh>=3.5.2
|
bokeh>=3.6.3
|
||||||
markupsafe>=2.1.5
|
markupsafe>=3.0.2
|
||||||
Markdown>=3.7
|
setproctitle>=1.3.5
|
||||||
mdx-breakless-lists>=1.0.1
|
mistune>=3.1.3
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
const gameInfo = document.getElementById('game-info');
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
if (ajax.status === 404) {
|
|
||||||
reject("Sorry, this game's info page is not available in that language yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ajax.status !== 200) {
|
|
||||||
reject("Something went wrong while loading the info page.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(ajax.responseText);
|
|
||||||
};
|
|
||||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
|
|
||||||
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
|
|
||||||
ajax.send();
|
|
||||||
}).then((results) => {
|
|
||||||
// Populate page with HTML generated from markdown
|
|
||||||
showdown.setOption('tables', true);
|
|
||||||
showdown.setOption('strikethrough', true);
|
|
||||||
showdown.setOption('literalMidWordUnderscores', true);
|
|
||||||
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
|
|
||||||
adjustHeaderWidth();
|
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
|
||||||
header.setAttribute('id', headerId);
|
|
||||||
header.addEventListener('click', () => {
|
|
||||||
window.location.hash = `#${headerId}`;
|
|
||||||
header.scrollIntoView();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
|
||||||
document.fonts.ready.finally(() => {
|
|
||||||
if (window.location.hash) {
|
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
|
||||||
scrollTarget?.scrollIntoView();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
gameInfo.innerHTML =
|
|
||||||
`<h2>This page is out of logic!</h2>
|
|
||||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,6 +6,4 @@ window.addEventListener('load', () => {
|
|||||||
document.getElementById('file-input').addEventListener('change', () => {
|
document.getElementById('file-input').addEventListener('change', () => {
|
||||||
document.getElementById('host-game-form').submit();
|
document.getElementById('host-game-form').submit();
|
||||||
});
|
});
|
||||||
|
|
||||||
adjustFooterHeight();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
// Reload tracker every 15 seconds
|
|
||||||
const url = window.location;
|
|
||||||
setInterval(() => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
|
|
||||||
// Create a fake DOM using the returned HTML
|
|
||||||
const domParser = new DOMParser();
|
|
||||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
|
||||||
|
|
||||||
// Update item tracker
|
|
||||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
|
||||||
// Update only counters in the location-table
|
|
||||||
let counters = document.getElementsByClassName('counter');
|
|
||||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
|
||||||
for (let i = 0; i < counters.length; i++) {
|
|
||||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ajax.open('GET', url);
|
|
||||||
ajax.send();
|
|
||||||
}, 15000)
|
|
||||||
|
|
||||||
// Collapsible advancement sections
|
|
||||||
const categories = document.getElementsByClassName("location-category");
|
|
||||||
for (let i = 0; i < categories.length; i++) {
|
|
||||||
let hide_id = categories[i].id.split('-')[0];
|
|
||||||
if (hide_id == 'Total') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
categories[i].addEventListener('click', function() {
|
|
||||||
// Toggle the advancement list
|
|
||||||
document.getElementById(hide_id).classList.toggle("hide");
|
|
||||||
// Change text of the header
|
|
||||||
const tab_header = document.getElementById(hide_id+'-header').children[0];
|
|
||||||
const orig_text = tab_header.innerHTML;
|
|
||||||
let new_text;
|
|
||||||
if (orig_text.includes("▼")) {
|
|
||||||
new_text = orig_text.replace("▼", "▲");
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
new_text = orig_text.replace("▲", "▼");
|
|
||||||
}
|
|
||||||
tab_header.innerHTML = new_text;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,49 +1,43 @@
|
|||||||
|
let updateSection = (sectionName, fakeDOM) => {
|
||||||
|
document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
// Reload tracker every 15 seconds
|
// Reload tracker every 60 seconds (sync'd)
|
||||||
const url = window.location;
|
const url = window.location;
|
||||||
setInterval(() => {
|
// Note: This synchronization code is adapted from code in trackerCommon.js
|
||||||
const ajax = new XMLHttpRequest();
|
const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3;
|
||||||
ajax.onreadystatechange = () => {
|
console.log("Target second of refresh: " + targetSecond);
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
|
|
||||||
// Create a fake DOM using the returned HTML
|
let getSleepTimeSeconds = () => {
|
||||||
const domParser = new DOMParser();
|
// -40 % 60 is -40, which is absolutely wrong and should burn
|
||||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60;
|
||||||
|
return sleepSeconds || 60;
|
||||||
// Update item tracker
|
|
||||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
|
||||||
// Update only counters in the location-table
|
|
||||||
let counters = document.getElementsByClassName('counter');
|
|
||||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
|
||||||
for (let i = 0; i < counters.length; i++) {
|
|
||||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
ajax.open('GET', url);
|
|
||||||
ajax.send();
|
|
||||||
}, 15000)
|
|
||||||
|
|
||||||
// Collapsible advancement sections
|
let updateTracker = () => {
|
||||||
const categories = document.getElementsByClassName("location-category");
|
const ajax = new XMLHttpRequest();
|
||||||
for (let category of categories) {
|
ajax.onreadystatechange = () => {
|
||||||
let hide_id = category.id.split('_')[0];
|
if (ajax.readyState !== 4) { return; }
|
||||||
if (hide_id === 'Total') {
|
|
||||||
continue;
|
// Create a fake DOM using the returned HTML
|
||||||
}
|
const domParser = new DOMParser();
|
||||||
category.addEventListener('click', function() {
|
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||||
// Toggle the advancement list
|
|
||||||
document.getElementById(hide_id).classList.toggle("hide");
|
// Update dynamic sections
|
||||||
// Change text of the header
|
updateSection('player-info', fakeDOM);
|
||||||
const tab_header = document.getElementById(hide_id+'_header').children[0];
|
updateSection('section-filler', fakeDOM);
|
||||||
const orig_text = tab_header.innerHTML;
|
updateSection('section-terran', fakeDOM);
|
||||||
let new_text;
|
updateSection('section-zerg', fakeDOM);
|
||||||
if (orig_text.includes("▼")) {
|
updateSection('section-protoss', fakeDOM);
|
||||||
new_text = orig_text.replace("▼", "▲");
|
updateSection('section-nova', fakeDOM);
|
||||||
}
|
updateSection('section-kerrigan', fakeDOM);
|
||||||
else {
|
updateSection('section-keys', fakeDOM);
|
||||||
new_text = orig_text.replace("▲", "▼");
|
updateSection('section-locations', fakeDOM);
|
||||||
}
|
};
|
||||||
tab_header.innerHTML = new_text;
|
ajax.open('GET', url);
|
||||||
});
|
ajax.send();
|
||||||
}
|
updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
||||||
|
};
|
||||||
|
window.updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
const adjustFooterHeight = () => {
|
|
||||||
// If there is no footer on this page, do nothing
|
|
||||||
const footer = document.getElementById('island-footer');
|
|
||||||
if (!footer) { return; }
|
|
||||||
|
|
||||||
// If the body is taller than the window, also do nothing
|
|
||||||
if (document.body.offsetHeight > window.innerHeight) {
|
|
||||||
footer.style.marginTop = '0';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a margin-top to the footer to position it at the bottom of the screen
|
|
||||||
const sibling = footer.previousElementSibling;
|
|
||||||
const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight);
|
|
||||||
if (margin < 1) {
|
|
||||||
footer.style.marginTop = '0';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
footer.style.marginTop = `${margin}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const adjustHeaderWidth = () => {
|
|
||||||
// If there is no header, do nothing
|
|
||||||
const header = document.getElementById('base-header');
|
|
||||||
if (!header) { return; }
|
|
||||||
|
|
||||||
const tempDiv = document.createElement('div');
|
|
||||||
tempDiv.style.width = '100px';
|
|
||||||
tempDiv.style.height = '100px';
|
|
||||||
tempDiv.style.overflow = 'scroll';
|
|
||||||
tempDiv.style.position = 'absolute';
|
|
||||||
tempDiv.style.top = '-500px';
|
|
||||||
document.body.appendChild(tempDiv);
|
|
||||||
const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth;
|
|
||||||
document.body.removeChild(tempDiv);
|
|
||||||
|
|
||||||
const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement;
|
|
||||||
const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0;
|
|
||||||
document.getElementById('base-header-right').style.marginRight = `${margin}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
window.addEventListener('resize', adjustFooterHeight);
|
|
||||||
window.addEventListener('resize', adjustHeaderWidth);
|
|
||||||
adjustFooterHeight();
|
|
||||||
adjustHeaderWidth();
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
window.addEventListener('load', () => {
|
|
||||||
const tutorialWrapper = document.getElementById('tutorial-wrapper');
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
if (ajax.status === 404) {
|
|
||||||
reject("Sorry, the tutorial is not available in that language yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ajax.status !== 200) {
|
|
||||||
reject("Something went wrong while loading the tutorial.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(ajax.responseText);
|
|
||||||
};
|
|
||||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
|
|
||||||
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
|
|
||||||
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
|
||||||
ajax.send();
|
|
||||||
}).then((results) => {
|
|
||||||
// Populate page with HTML generated from markdown
|
|
||||||
showdown.setOption('tables', true);
|
|
||||||
showdown.setOption('strikethrough', true);
|
|
||||||
showdown.setOption('literalMidWordUnderscores', true);
|
|
||||||
showdown.setOption('disableForced4SpacesIndentedSublists', true);
|
|
||||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
|
||||||
adjustHeaderWidth();
|
|
||||||
|
|
||||||
const title = document.querySelector('h1')
|
|
||||||
if (title) {
|
|
||||||
document.title = title.textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the id of all header divs to something nicer
|
|
||||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
|
||||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
|
||||||
header.setAttribute('id', headerId);
|
|
||||||
header.addEventListener('click', () => {
|
|
||||||
window.location.hash = `#${headerId}`;
|
|
||||||
header.scrollIntoView();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
|
||||||
document.fonts.ready.finally(() => {
|
|
||||||
if (window.location.hash) {
|
|
||||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
|
||||||
scrollTarget?.scrollIntoView();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
tutorialWrapper.innerHTML =
|
|
||||||
`<h2>This page is out of logic!</h2>
|
|
||||||
<h3>Click <a href="${window.location.origin}/tutorial">here</a> to return to safety.</h3>`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
const showError = () => {
|
|
||||||
const tutorial = document.getElementById('tutorial-landing');
|
|
||||||
document.getElementById('page-title').innerText = 'This page is out of logic!';
|
|
||||||
tutorial.removeChild(document.getElementById('loading'));
|
|
||||||
const userMessage = document.createElement('h3');
|
|
||||||
const homepageLink = document.createElement('a');
|
|
||||||
homepageLink.innerText = 'Click here';
|
|
||||||
homepageLink.setAttribute('href', '/');
|
|
||||||
userMessage.append(homepageLink);
|
|
||||||
userMessage.append(' to go back to safety!');
|
|
||||||
tutorial.append(userMessage);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
const ajax = new XMLHttpRequest();
|
|
||||||
ajax.onreadystatechange = () => {
|
|
||||||
if (ajax.readyState !== 4) { return; }
|
|
||||||
const tutorialDiv = document.getElementById('tutorial-landing');
|
|
||||||
if (ajax.status !== 200) { return showError(); }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const games = JSON.parse(ajax.responseText);
|
|
||||||
games.forEach((game) => {
|
|
||||||
const gameTitle = document.createElement('h2');
|
|
||||||
gameTitle.innerText = game.gameTitle;
|
|
||||||
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
|
|
||||||
tutorialDiv.appendChild(gameTitle);
|
|
||||||
|
|
||||||
game.tutorials.forEach((tutorial) => {
|
|
||||||
const tutorialName = document.createElement('h3');
|
|
||||||
tutorialName.innerText = tutorial.name;
|
|
||||||
tutorialDiv.appendChild(tutorialName);
|
|
||||||
|
|
||||||
const tutorialDescription = document.createElement('p');
|
|
||||||
tutorialDescription.innerText = tutorial.description;
|
|
||||||
tutorialDiv.appendChild(tutorialDescription);
|
|
||||||
|
|
||||||
const intro = document.createElement('p');
|
|
||||||
intro.innerText = 'This guide is available in the following languages:';
|
|
||||||
tutorialDiv.appendChild(intro);
|
|
||||||
|
|
||||||
const fileList = document.createElement('ul');
|
|
||||||
tutorial.files.forEach((file) => {
|
|
||||||
const listItem = document.createElement('li');
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.innerText = file.language;
|
|
||||||
anchor.setAttribute('href', `${window.location.origin}/tutorial/${file.link}`);
|
|
||||||
listItem.appendChild(anchor);
|
|
||||||
|
|
||||||
listItem.append(' by ');
|
|
||||||
for (let author of file.authors) {
|
|
||||||
listItem.append(author);
|
|
||||||
if (file.authors.indexOf(author) !== (file.authors.length -1)) {
|
|
||||||
listItem.append(', ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileList.appendChild(listItem);
|
|
||||||
});
|
|
||||||
tutorialDiv.appendChild(fileList);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tutorialDiv.removeChild(document.getElementById('loading'));
|
|
||||||
} catch (error) {
|
|
||||||
showError();
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we are on an anchor when coming in, and scroll to it.
|
|
||||||
const hash = window.location.hash;
|
|
||||||
if (hash) {
|
|
||||||
const offset = 128; // To account for navbar banner at top of page.
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
|
|
||||||
window.scrollTo(rect.left, rect.top - offset);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
|
|
||||||
ajax.send();
|
|
||||||
});
|
|
||||||
@@ -36,6 +36,13 @@ html{
|
|||||||
|
|
||||||
body{
|
body{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: calc(100vh - 110px);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
a{
|
a{
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-family: LondrinaSolid-Regular, sans-serif;
|
font-family: LondrinaSolid-Regular, sans-serif;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-shadow: 1px 1px 4px #000000;
|
text-shadow: 1px 1px 4px #000000;
|
||||||
}
|
}
|
||||||
@@ -37,7 +36,6 @@
|
|||||||
font-size: 38px;
|
font-size: 38px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-family: LondrinaSolid-Light, sans-serif;
|
font-family: LondrinaSolid-Light, sans-serif;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
@@ -50,7 +48,6 @@
|
|||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -59,7 +56,6 @@
|
|||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,14 +63,12 @@
|
|||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h6, .markdown details summary.h6{
|
.markdown h6, .markdown details summary.h6{
|
||||||
font-family: LexendDeca-Regular, sans-serif;
|
font-family: LexendDeca-Regular, sans-serif;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
cursor: pointer; /* TODO: remove once we drop showdown.js */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown h4, .markdown h5, .markdown h6{
|
.markdown h4, .markdown h5, .markdown h6{
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
#player-tracker-wrapper{
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table{
|
|
||||||
border-top: 2px solid #000000;
|
|
||||||
border-left: 2px solid #000000;
|
|
||||||
border-right: 2px solid #000000;
|
|
||||||
border-top-left-radius: 4px;
|
|
||||||
border-top-right-radius: 4px;
|
|
||||||
padding: 3px 3px 10px;
|
|
||||||
width: 384px;
|
|
||||||
background-color: #42b149;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table td{
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
text-align: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table img{
|
|
||||||
height: 100%;
|
|
||||||
max-width: 40px;
|
|
||||||
max-height: 40px;
|
|
||||||
filter: grayscale(100%) contrast(75%) brightness(30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table img.acquired{
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table div.counted-item {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
#inventory-table div.item-count {
|
|
||||||
position: absolute;
|
|
||||||
color: white;
|
|
||||||
font-family: "Minecraftia", monospace;
|
|
||||||
font-weight: bold;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table{
|
|
||||||
width: 384px;
|
|
||||||
border-left: 2px solid #000000;
|
|
||||||
border-right: 2px solid #000000;
|
|
||||||
border-bottom: 2px solid #000000;
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
border-bottom-right-radius: 4px;
|
|
||||||
background-color: #42b149;
|
|
||||||
padding: 0 3px 3px;
|
|
||||||
font-family: "Minecraftia", monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table th{
|
|
||||||
vertical-align: middle;
|
|
||||||
text-align: left;
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td{
|
|
||||||
padding-top: 2px;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.counter {
|
|
||||||
text-align: right;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.toggle-arrow {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table tr#Total-header {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table img{
|
|
||||||
height: 100%;
|
|
||||||
max-width: 30px;
|
|
||||||
max-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table tbody.locations {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.location-name {
|
|
||||||
padding-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
@@ -1,160 +1,279 @@
|
|||||||
#player-tracker-wrapper{
|
*{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: "JuraBook", monospace;
|
||||||
|
}
|
||||||
|
body{
|
||||||
|
--icon-size: 36px;
|
||||||
|
--item-class-padding: 4px;
|
||||||
|
}
|
||||||
|
a{
|
||||||
|
color: #1ae;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tracker-table td {
|
/* Section colours */
|
||||||
vertical-align: top;
|
#player-info{
|
||||||
|
background-color: #37a;
|
||||||
|
}
|
||||||
|
.player-tracker{
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.tracker-section{
|
||||||
|
background-color: grey;
|
||||||
|
}
|
||||||
|
#terran-items{
|
||||||
|
background-color: #3a7;
|
||||||
|
}
|
||||||
|
#zerg-items{
|
||||||
|
background-color: #d94;
|
||||||
|
}
|
||||||
|
#protoss-items{
|
||||||
|
background-color: #37a;
|
||||||
|
}
|
||||||
|
#nova-items{
|
||||||
|
background-color: #777;
|
||||||
|
}
|
||||||
|
#kerrigan-items{
|
||||||
|
background-color: #a37;
|
||||||
|
}
|
||||||
|
#keys{
|
||||||
|
background-color: #aa2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table-area{
|
/* Sections */
|
||||||
border: 2px solid #000000;
|
.section-body{
|
||||||
border-radius: 4px;
|
display: flex;
|
||||||
padding: 3px 10px 3px 10px;
|
flex-flow: row wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
.section-body-2{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body,
|
||||||
|
.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body-2{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.section-title{
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 3px solid black;
|
||||||
|
/* Prevent text selection */
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
input[type="checkbox"]{
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.section-title:hover h2{
|
||||||
|
text-shadow: 0 0 4px #ddd;
|
||||||
|
}
|
||||||
|
.f {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table-area:has(.inventory-table-terran) {
|
/* Acquire item filters */
|
||||||
width: 690px;
|
.tracker-section img{
|
||||||
background-color: #525494;
|
height: 100%;
|
||||||
|
width: var(--icon-size);
|
||||||
|
height: var(--icon-size);
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
.unacquired, .lvl-0 .f{
|
||||||
|
filter: grayscale(100%) contrast(80%) brightness(42%) blur(0.5px);
|
||||||
|
}
|
||||||
|
.spacer{
|
||||||
|
width: var(--icon-size);
|
||||||
|
height: var(--icon-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table-area:has(.inventory-table-zerg) {
|
/* Item groups */
|
||||||
width: 360px;
|
.item-class{
|
||||||
background-color: #9d60d2;
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--item-class-padding);
|
||||||
|
}
|
||||||
|
.item-class-header{
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row;
|
||||||
|
}
|
||||||
|
.item-class-upgrades{
|
||||||
|
/* Note: {display: flex; flex-flow: column wrap} */
|
||||||
|
/* just breaks on Firefox (width does not scale to content) */
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(4, auto);
|
||||||
|
grid-auto-flow: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table-area:has(.inventory-table-protoss) {
|
/* Subsections */
|
||||||
width: 400px;
|
.section-toc{
|
||||||
background-color: #d2b260;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.toc-box{
|
||||||
|
position: relative;
|
||||||
|
padding-left: 15px;
|
||||||
|
padding-right: 15px;
|
||||||
|
}
|
||||||
|
.toc-box:hover{
|
||||||
|
text-shadow: 0 0 7px white;
|
||||||
|
}
|
||||||
|
.ss-header{
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
writing-mode: sideways-lr;
|
||||||
|
user-select: none;
|
||||||
|
padding-top: 5px;
|
||||||
|
font-size: 115%;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-1-toggle:checked) .ss-1{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-2-toggle:checked) .ss-2{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-3-toggle:checked) .ss-3{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-4-toggle:checked) .ss-4{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-5-toggle:checked) .ss-5{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-6-toggle:checked) .ss-6{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-7-toggle:checked) .ss-7{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-1-toggle:hover) .ss-1{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-2-toggle:hover) .ss-2{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-3-toggle:hover) .ss-3{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-4-toggle:hover) .ss-4{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-5-toggle:hover) .ss-5{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-6-toggle:hover) .ss-6{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
|
}
|
||||||
|
.tracker-section:has(input.ss-7-toggle:hover) .ss-7{
|
||||||
|
background-color: #fff5;
|
||||||
|
box-shadow: 0 0 1px 1px white;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tracker-table .inventory-table td{
|
/* Progressive items */
|
||||||
width: 40px;
|
.progressive{
|
||||||
height: 40px;
|
max-height: var(--icon-size);
|
||||||
text-align: center;
|
display: contents;
|
||||||
vertical-align: middle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table td.title{
|
.lvl-0 > :nth-child(2),
|
||||||
padding-top: 10px;
|
.lvl-0 > :nth-child(3),
|
||||||
height: 20px;
|
.lvl-0 > :nth-child(4),
|
||||||
font-family: "JuraBook", monospace;
|
.lvl-0 > :nth-child(5){
|
||||||
font-size: 16px;
|
display: none;
|
||||||
font-weight: bold;
|
}
|
||||||
|
.lvl-1 > :nth-child(2),
|
||||||
|
.lvl-1 > :nth-child(3),
|
||||||
|
.lvl-1 > :nth-child(4),
|
||||||
|
.lvl-1 > :nth-child(5){
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lvl-2 > :nth-child(1),
|
||||||
|
.lvl-2 > :nth-child(3),
|
||||||
|
.lvl-2 > :nth-child(4),
|
||||||
|
.lvl-2 > :nth-child(5){
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lvl-3 > :nth-child(1),
|
||||||
|
.lvl-3 > :nth-child(2),
|
||||||
|
.lvl-3 > :nth-child(4),
|
||||||
|
.lvl-3 > :nth-child(5){
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lvl-4 > :nth-child(1),
|
||||||
|
.lvl-4 > :nth-child(2),
|
||||||
|
.lvl-4 > :nth-child(3),
|
||||||
|
.lvl-4 > :nth-child(5){
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lvl-5 > :nth-child(1),
|
||||||
|
.lvl-5 > :nth-child(2),
|
||||||
|
.lvl-5 > :nth-child(3),
|
||||||
|
.lvl-5 > :nth-child(4){
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table img{
|
/* Filler item counters */
|
||||||
height: 100%;
|
.item-counter{
|
||||||
max-width: 40px;
|
display: table;
|
||||||
max-height: 40px;
|
text-align: center;
|
||||||
border: 1px solid #000000;
|
padding: var(--item-class-padding);
|
||||||
filter: grayscale(100%) contrast(75%) brightness(20%);
|
}
|
||||||
background-color: black;
|
.item-count{
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: middle;
|
||||||
|
padding-left: 3px;
|
||||||
|
padding-right: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table img.acquired{
|
/* Hidden items */
|
||||||
filter: none;
|
.hidden-class:not(:has(img.acquired)){
|
||||||
background-color: black;
|
display: none;
|
||||||
|
}
|
||||||
|
.hidden-item:not(.acquired){
|
||||||
|
display:none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table .tint-terran img.acquired {
|
/* Keys */
|
||||||
filter: sepia(100%) saturate(300%) brightness(130%) hue-rotate(120deg)
|
#keys ol, #keys ul{
|
||||||
|
columns: 3;
|
||||||
|
-webkit-columns: 3;
|
||||||
|
-moz-columns: 3;
|
||||||
|
}
|
||||||
|
#keys li{
|
||||||
|
padding-right: 15pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table .tint-protoss img.acquired {
|
/* Locations */
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(180deg)
|
#section-locations{
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
@media only screen and (min-width: 120ch){
|
||||||
|
#section-locations ul{
|
||||||
|
columns: 2;
|
||||||
|
-webkit-columns: 2;
|
||||||
|
-moz-columns: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#locations li.checked{
|
||||||
|
list-style-type: "✔ ";
|
||||||
}
|
}
|
||||||
|
|
||||||
.inventory-table .tint-level-1 img.acquired {
|
/* Allowing scrolling down a little further */
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg)
|
.bottom-padding{
|
||||||
}
|
min-height: 33vh;
|
||||||
|
}
|
||||||
.inventory-table .tint-level-2 img.acquired {
|
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(120deg)
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table .tint-level-3 img.acquired {
|
|
||||||
filter: sepia(100%) saturate(1000%) brightness(110%) hue-rotate(60deg) hue-rotate(240deg)
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table div.counted-item {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inventory-table div.item-count {
|
|
||||||
width: 160px;
|
|
||||||
text-align: left;
|
|
||||||
color: black;
|
|
||||||
font-family: "JuraBook", monospace;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table{
|
|
||||||
border: 2px solid #000000;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #87b678;
|
|
||||||
padding: 10px 3px 3px;
|
|
||||||
font-family: "JuraBook", monospace;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table table{
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table th{
|
|
||||||
vertical-align: middle;
|
|
||||||
text-align: left;
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td{
|
|
||||||
padding-top: 2px;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.counter {
|
|
||||||
text-align: right;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.toggle-arrow {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table tr#Total-header {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table img{
|
|
||||||
height: 100%;
|
|
||||||
max-width: 30px;
|
|
||||||
max-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table tbody.locations {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td.location-name {
|
|
||||||
padding-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table td:has(.location-column) {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table .location-column {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#location-table .location-column .spacer {
|
|
||||||
min-height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
3965
WebHostLib/static/styles/sc2TrackerAtlas.css
Normal file
3965
WebHostLib/static/styles/sc2TrackerAtlas.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
|||||||
import typing
|
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from colorsys import hsv_to_rgb
|
from colorsys import hsv_to_rgb
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
@@ -18,21 +17,23 @@ from .models import Room
|
|||||||
PLOT_WIDTH = 600
|
PLOT_WIDTH = 600
|
||||||
|
|
||||||
|
|
||||||
def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
|
def get_db_data(known_games: set[str]) -> tuple[Counter[str], defaultdict[date, dict[str, int]]]:
|
||||||
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
|
games_played: defaultdict[date, dict[str, int]] = defaultdict(Counter)
|
||||||
games_played = defaultdict(Counter)
|
total_games: Counter[str] = Counter()
|
||||||
total_games = Counter()
|
|
||||||
cutoff = date.today() - timedelta(days=30)
|
cutoff = date.today() - timedelta(days=30)
|
||||||
room: Room
|
room: Room
|
||||||
for room in select(room for room in Room if room.creation_time >= cutoff):
|
for room in select(room for room in Room if room.creation_time >= cutoff):
|
||||||
for slot in room.seed.slots:
|
for slot in room.seed.slots:
|
||||||
if slot.game in known_games:
|
if slot.game in known_games:
|
||||||
total_games[slot.game] += 1
|
current_game = slot.game
|
||||||
games_played[room.creation_time.date()][slot.game] += 1
|
else:
|
||||||
|
current_game = "Other"
|
||||||
|
total_games[current_game] += 1
|
||||||
|
games_played[room.creation_time.date()][current_game] += 1
|
||||||
return total_games, games_played
|
return total_games, games_played
|
||||||
|
|
||||||
|
|
||||||
def get_color_palette(colors_needed: int) -> typing.List[RGB]:
|
def get_color_palette(colors_needed: int) -> list[RGB]:
|
||||||
colors = []
|
colors = []
|
||||||
# colors_needed +1 to prevent first and last color being too close to each other
|
# colors_needed +1 to prevent first and last color being too close to each other
|
||||||
colors_needed += 1
|
colors_needed += 1
|
||||||
@@ -47,8 +48,7 @@ def get_color_palette(colors_needed: int) -> typing.List[RGB]:
|
|||||||
return colors
|
return colors
|
||||||
|
|
||||||
|
|
||||||
def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
|
def create_game_played_figure(all_games_data: dict[date, dict[str, int]], game: str, color: RGB) -> figure:
|
||||||
game: str, color: RGB) -> figure:
|
|
||||||
occurences = []
|
occurences = []
|
||||||
days = [day for day, game_data in all_games_data.items() if game_data[game]]
|
days = [day for day, game_data in all_games_data.items() if game_data[game]]
|
||||||
for day in days:
|
for day in days:
|
||||||
@@ -84,7 +84,7 @@ def stats():
|
|||||||
days = sorted(games_played)
|
days = sorted(games_played)
|
||||||
|
|
||||||
color_palette = get_color_palette(len(total_games))
|
color_palette = get_color_palette(len(total_games))
|
||||||
game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
|
game_to_color: dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
|
||||||
|
|
||||||
for game in sorted(total_games):
|
for game in sorted(total_games):
|
||||||
occurences = []
|
occurences = []
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Page Not Found (404)</title>
|
<title>Page Not Found (404)</title>
|
||||||
@@ -13,5 +14,4 @@
|
|||||||
The page you're looking for doesn't exist.<br />
|
The page you're looking for doesn't exist.<br />
|
||||||
<a href="/">Click here to return to safety.</a>
|
<a href="/">Click here to return to safety.</a>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<title>{{ game }} Info</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
|
||||||
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/gameInfo.js") }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
{% include 'header/'+theme+'Header.html' %}
|
|
||||||
<div id="game-info" class="markdown" data-lang="{{ lang }}" data-game="{{ game | get_file_safe_name }}">
|
|
||||||
<!-- Populated my JS / MD -->
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Upload Multidata</title>
|
<title>Upload Multidata</title>
|
||||||
@@ -27,6 +28,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Archipelago</title>
|
<title>Archipelago</title>
|
||||||
@@ -57,5 +58,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -26,30 +26,18 @@
|
|||||||
<td>{{ patch.game }}</td>
|
<td>{{ patch.game }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if patch.data %}
|
{% if patch.data %}
|
||||||
{% if patch.game == "Minecraft" %}
|
{% if patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download APMC File...</a>
|
|
||||||
{% elif patch.game == "Factorio" %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download Factorio Mod...</a>
|
|
||||||
{% elif patch.game == "Kingdom Hearts 2" %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download Kingdom Hearts 2 Mod...</a>
|
|
||||||
{% elif patch.game == "Ocarina of Time" %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download APZ5 File...</a>
|
|
||||||
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download APV6 File...</a>
|
Download APV6 File...</a>
|
||||||
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
Download APSM64EX File...</a>
|
Download APSM64EX File...</a>
|
||||||
{% elif patch.game | supports_apdeltapatch %}
|
{% elif patch.game == "Factorio" %}
|
||||||
|
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||||
|
Download Factorio Mod...</a>
|
||||||
|
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %}
|
||||||
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
|
||||||
Download Patch File...</a>
|
Download Patch File...</a>
|
||||||
{% elif patch.game == "Final Fantasy Mystic Quest" %}
|
|
||||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
|
||||||
Download APMQ File...</a>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
No file to download for this game.
|
No file to download for this game.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% include 'header/grassHeader.html' %}
|
{% set theme_name = theme|default("grass", true) %}
|
||||||
|
{% include "header/"+theme_name+"Header.html" %}
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,26 +5,29 @@
|
|||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/styleController.js") }}"></script>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
|
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Archipelago</title>
|
<title>Archipelago</title>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<div>
|
||||||
|
{% for message in messages | unique %}
|
||||||
|
<div class="user-message">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
{% with messages = get_flashed_messages() %}
|
{% block body %}
|
||||||
{% if messages %}
|
{% endblock %}
|
||||||
<div>
|
</main>
|
||||||
{% for message in messages | unique %}
|
|
||||||
<div class="user-message">{{ message }}</div>
|
{% if show_footer %}
|
||||||
{% endfor %}
|
{% include "islandFooter.html" %}
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -111,10 +111,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro ItemDict(option_name, option) %}
|
{% macro OptionCounter(option_name, option) %}
|
||||||
|
{% set relevant_keys = option.valid_keys %}
|
||||||
|
{% if not relevant_keys %}
|
||||||
|
{% if option.verify_item_name %}
|
||||||
|
{% set relevant_keys = world.item_names %}
|
||||||
|
{% elif option.verify_location_name %}
|
||||||
|
{% set relevant_keys = world.location_names %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
||||||
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
|
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
|
||||||
@@ -125,6 +134,7 @@
|
|||||||
|
|
||||||
{% macro OptionList(option_name, option) %}
|
{% macro OptionList(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
@@ -137,6 +147,7 @@
|
|||||||
|
|
||||||
{% macro LocationSet(option_name, option) %}
|
{% macro LocationSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for group_name in world.location_name_groups.keys()|sort %}
|
{% for group_name in world.location_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everywhere" %}
|
{% if group_name != "Everywhere" %}
|
||||||
@@ -160,6 +171,7 @@
|
|||||||
|
|
||||||
{% macro ItemSet(option_name, option) %}
|
{% macro ItemSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for group_name in world.item_name_groups.keys()|sort %}
|
{% for group_name in world.item_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everything" %}
|
{% if group_name != "Everything" %}
|
||||||
@@ -183,6 +195,7 @@
|
|||||||
|
|
||||||
{% macro OptionSet(option_name, option) %}
|
{% macro OptionSet(option_name, option) %}
|
||||||
{{ OptionTitle(option_name, option) }}
|
{{ OptionTitle(option_name, option) }}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="option-container">
|
<div class="option-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="option-entry">
|
<div class="option-entry">
|
||||||
|
|||||||
@@ -93,8 +93,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option) }}
|
option.valid_keys or option.verify_item_name or option.verify_location_name
|
||||||
|
) %}
|
||||||
|
{{ inputs.OptionCounter(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
@@ -133,8 +135,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option) }}
|
option.valid_keys or option.verify_item_name or option.verify_location_name
|
||||||
|
) %}
|
||||||
|
{{ inputs.OptionCounter(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Generation failed, please retry.</title>
|
<title>Generation failed, please retry.</title>
|
||||||
@@ -15,5 +16,4 @@
|
|||||||
{{ seed_error }}
|
{{ seed_error }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -11,32 +11,32 @@
|
|||||||
<h1>Site Map</h1>
|
<h1>Site Map</h1>
|
||||||
<h2>Base Pages</h2>
|
<h2>Base Pages</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/discord">Discord Link</a></li>
|
<li><a href="{{ url_for('discord') }}">Discord Link</a></li>
|
||||||
<li><a href="/faq/en">F.A.Q. Page</a></li>
|
<li><a href="{{ url_for('faq', lang='en') }}">F.A.Q. Page</a></li>
|
||||||
<li><a href="/favicon.ico">Favicon</a></li>
|
<li><a href="{{ url_for('favicon') }}">Favicon</a></li>
|
||||||
<li><a href="/generate">Generate Game Page</a></li>
|
<li><a href="{{ url_for('generate') }}">Generate Game Page</a></li>
|
||||||
<li><a href="/">Homepage</a></li>
|
<li><a href="{{ url_for('landing') }}">Homepage</a></li>
|
||||||
<li><a href="/uploads">Host Game Page</a></li>
|
<li><a href="{{ url_for('uploads') }}">Host Game Page</a></li>
|
||||||
<li><a href="/datapackage">Raw Data Package</a></li>
|
<li><a href="{{ url_for('get_datapackage') }}">Raw Data Package</a></li>
|
||||||
<li><a href="{{ url_for('check')}}">Settings Validator</a></li>
|
<li><a href="{{ url_for('check') }}">Settings Validator</a></li>
|
||||||
<li><a href="/sitemap">Site Map</a></li>
|
<li><a href="{{ url_for('get_sitemap') }}">Site Map</a></li>
|
||||||
<li><a href="/start-playing">Start Playing</a></li>
|
<li><a href="{{ url_for('start_playing') }}">Start Playing</a></li>
|
||||||
<li><a href="/games">Supported Games Page</a></li>
|
<li><a href="{{ url_for('games') }}">Supported Games Page</a></li>
|
||||||
<li><a href="/tutorial">Tutorials Page</a></li>
|
<li><a href="{{ url_for('tutorial_landing') }}">Tutorials Page</a></li>
|
||||||
<li><a href="/user-content">User Content</a></li>
|
<li><a href="{{ url_for('user_content') }}">User Content</a></li>
|
||||||
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
|
<li><a href="{{ url_for('stats') }}">Game Statistics</a></li>
|
||||||
<li><a href="/glossary/en">Glossary</a></li>
|
<li><a href="{{ url_for('glossary', lang='en') }}">Glossary</a></li>
|
||||||
<li><a href="{{url_for("show_session")}}">Session / Login</a></li>
|
<li><a href="{{ url_for('show_session') }}">Session / Login</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Tutorials</h2>
|
<h2>Tutorials</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/tutorial/Archipelago/setup/en">Multiworld Setup Tutorial</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='setup_en') }}">Multiworld Setup Tutorial</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/mac/en">Setup Guide for Mac</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='mac_en') }}">Setup Guide for Mac</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/commands/en">Server and Client Commands</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='commands_en') }}">Server and Client Commands</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/advanced_settings/en">Advanced YAML Guide</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='advanced_settings_en') }}">Advanced YAML Guide</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/triggers/en">Triggers Guide</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='triggers_en') }}">Triggers Guide</a></li>
|
||||||
<li><a href="/tutorial/Archipelago/plando/en">Plando Guide</a></li>
|
<li><a href="{{ url_for('tutorial', game='Archipelago', file='plando_en') }}">Plando Guide</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Game Info Pages</h2>
|
<h2>Game Info Pages</h2>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Start Playing</title>
|
<title>Start Playing</title>
|
||||||
@@ -26,6 +27,4 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>{{ player_name }}'s Tracker</title>
|
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
|
|
||||||
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
|
|
||||||
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
|
|
||||||
<div style="margin-bottom: 0.5rem">
|
|
||||||
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
|
||||||
<table id="inventory-table">
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
|
|
||||||
<td><img src="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
|
|
||||||
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></td>
|
|
||||||
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
|
|
||||||
title="Progressive Resource Crafting" /></td>
|
|
||||||
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
|
|
||||||
<td>
|
|
||||||
<div class="counted-item">
|
|
||||||
<img src="{{ icons['Ender Pearl'] }}" class="{{ 'acquired' if '3 Ender Pearls' in acquired_items }}" title="Ender Pearls" />
|
|
||||||
<div class="item-count">{{ pearls_count }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons['Bucket'] }}" class="{{ 'acquired' if 'Bucket' in acquired_items }}" title="Bucket" /></td>
|
|
||||||
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Archery' in acquired_items }}" title="Archery" /></td>
|
|
||||||
<td><img src="{{ icons['Shield'] }}" class="{{ 'acquired' if 'Shield' in acquired_items }}" title="Shield" /></td>
|
|
||||||
<td><img src="{{ icons['Red Bed'] }}" class="{{ 'acquired' if 'Bed' in acquired_items }}" title="Bed" /></td>
|
|
||||||
<td><img src="{{ icons['Water Bottle'] }}" class="{{ 'acquired' if 'Bottles' in acquired_items }}" title="Bottles" /></td>
|
|
||||||
<td>
|
|
||||||
<div class="counted-item">
|
|
||||||
<img src="{{ icons['Netherite Scrap'] }}" class="{{ 'acquired' if '8 Netherite Scrap' in acquired_items }}" title="Netherite Scrap" />
|
|
||||||
<div class="item-count">{{ scrap_count }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons['Flint and Steel'] }}" class="{{ 'acquired' if 'Flint and Steel' in acquired_items }}" title="Flint and Steel" /></td>
|
|
||||||
<td><img src="{{ icons['Enchanting Table'] }}" class="{{ 'acquired' if 'Enchanting' in acquired_items }}" title="Enchanting" /></td>
|
|
||||||
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
|
|
||||||
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
|
|
||||||
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
|
|
||||||
<td>
|
|
||||||
<div class="counted-item">
|
|
||||||
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
|
|
||||||
<div class="item-count">{{ shard_count }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
|
|
||||||
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
|
|
||||||
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
|
|
||||||
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
|
|
||||||
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table id="location-table">
|
|
||||||
{% for area in checks_done %}
|
|
||||||
<tr class="location-category" id="{{area}}-header">
|
|
||||||
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
|
|
||||||
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
|
|
||||||
</tr>
|
|
||||||
<tbody class="locations hide" id="{{area}}">
|
|
||||||
{% for location in location_info[area] %}
|
|
||||||
<tr>
|
|
||||||
<td class="location-name">{{ location }}</td>
|
|
||||||
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
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 %}
|
{% block head %}
|
||||||
{% include 'header/grassHeader.html' %}
|
{% include 'header/grassHeader.html' %}
|
||||||
<title>Archipelago Guides</title>
|
<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/markdown.css") }}"/>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div id="tutorial-landing" class="markdown" data-game="{{ game }}" data-file="{{ file }}" data-lang="{{ lang }}">
|
<div id="tutorial-landing" class="markdown">
|
||||||
<h1 id="page-title">Archipelago Guides</h1>
|
<h1>Archipelago Guides</h1>
|
||||||
<p id="loading">Loading...</p>
|
{% 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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
<div id="user-content-wrapper" class="markdown">
|
<div id="user-content-wrapper" class="markdown">
|
||||||
<div id="user-content" class="grass-island">
|
<div id="user-content" class="grass-island">
|
||||||
<h1>User Content</h1>
|
<h1>User Content</h1>
|
||||||
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.
|
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.<br/>
|
||||||
|
Sessions can be saved or synced across devices using the <a href="{{url_for('show_session')}}">Sessions Page.</a>
|
||||||
|
|
||||||
<h2>Your Rooms</h2>
|
<h2>Your Rooms</h2>
|
||||||
{% if rooms %}
|
{% if rooms %}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>View Seed {{ seed.id|suuid }}</title>
|
<title>View Seed {{ seed.id|suuid }}</title>
|
||||||
@@ -50,5 +51,4 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
{% extends 'pageWrapper.html' %}
|
{% extends 'pageWrapper.html' %}
|
||||||
{% import "macros.html" as macros %}
|
{% import "macros.html" as macros %}
|
||||||
|
{% set show_footer = True %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>Generation in Progress</title>
|
<title>Generation in Progress</title>
|
||||||
<meta http-equiv="refresh" content="1">
|
<noscript>
|
||||||
|
<meta http-equiv="refresh" content="1">
|
||||||
|
</noscript>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -15,5 +18,34 @@
|
|||||||
Waiting for game to generate, this page auto-refreshes to check.
|
Waiting for game to generate, this page auto-refreshes to check.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'islandFooter.html' %}
|
<script>
|
||||||
|
const waitSeedDiv = document.getElementById("wait-seed");
|
||||||
|
async function checkStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("{{ url_for('api.wait_seed_api', seed=seed_id) }}");
|
||||||
|
if (response.status !== 202) {
|
||||||
|
// Seed is ready; reload page to load seed page.
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
waitSeedDiv.innerHTML = `
|
||||||
|
<h1>Generation in Progress</h1>
|
||||||
|
<p>${data.text}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
setTimeout(checkStatus, 1000); // Continue polling.
|
||||||
|
} catch (error) {
|
||||||
|
waitSeedDiv.innerHTML = `
|
||||||
|
<h1>Progress Unknown</h1>
|
||||||
|
<p>${error.message}<br />(Last checked: ${new Date().toLocaleTimeString()})</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
setTimeout(checkStatus, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(checkStatus, 1000);
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -113,9 +113,18 @@
|
|||||||
{{ TextChoice(option_name, option) }}
|
{{ TextChoice(option_name, option) }}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro ItemDict(option_name, option, world) %}
|
{% macro OptionCounter(option_name, option, world) %}
|
||||||
|
{% set relevant_keys = option.valid_keys %}
|
||||||
|
{% if not relevant_keys %}
|
||||||
|
{% if option.verify_item_name %}
|
||||||
|
{% set relevant_keys = world.item_names %}
|
||||||
|
{% elif option.verify_location_name %}
|
||||||
|
{% set relevant_keys = world.location_names %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="dict-container">
|
<div class="dict-container">
|
||||||
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
|
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
|
||||||
<div class="dict-entry">
|
<div class="dict-entry">
|
||||||
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
|
||||||
<input
|
<input
|
||||||
@@ -130,6 +139,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro OptionList(option_name, option) %}
|
{% macro OptionList(option_name, option) %}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="list-container">
|
<div class="list-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="list-entry">
|
<div class="list-entry">
|
||||||
@@ -149,6 +159,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro LocationSet(option_name, option, world) %}
|
{% 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">
|
<div class="set-container">
|
||||||
{% for group_name in world.location_name_groups.keys()|sort %}
|
{% for group_name in world.location_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everywhere" %}
|
{% if group_name != "Everywhere" %}
|
||||||
@@ -171,6 +182,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro ItemSet(option_name, option, world) %}
|
{% 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">
|
<div class="set-container">
|
||||||
{% for group_name in world.item_name_groups.keys()|sort %}
|
{% for group_name in world.item_name_groups.keys()|sort %}
|
||||||
{% if group_name != "Everything" %}
|
{% if group_name != "Everything" %}
|
||||||
@@ -193,6 +205,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro OptionSet(option_name, option) %}
|
{% macro OptionSet(option_name, option) %}
|
||||||
|
<input type="hidden" id="{{ option_name }}-{{ key }}-hidden" name="{{ option_name }}" value="_ensure-empty-list"/>
|
||||||
<div class="set-container">
|
<div class="set-container">
|
||||||
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
|
||||||
<div class="set-entry">
|
<div class="set-entry">
|
||||||
|
|||||||
@@ -83,8 +83,10 @@
|
|||||||
{% elif issubclass(option, Options.FreeText) %}
|
{% elif issubclass(option, Options.FreeText) %}
|
||||||
{{ inputs.FreeText(option_name, option) }}
|
{{ inputs.FreeText(option_name, option) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
|
{% elif issubclass(option, Options.OptionCounter) and (
|
||||||
{{ inputs.ItemDict(option_name, option, world) }}
|
option.valid_keys or option.verify_item_name or option.verify_location_name
|
||||||
|
) %}
|
||||||
|
{{ inputs.OptionCounter(option_name, option, world) }}
|
||||||
|
|
||||||
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
|
||||||
{{ inputs.OptionList(option_name, option) }}
|
{{ inputs.OptionList(option_name, option) }}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
|||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import pickle
|
import pickle
|
||||||
import typing
|
import typing
|
||||||
@@ -14,9 +13,8 @@ from pony.orm.core import TransactionIntegrityError
|
|||||||
import schema
|
import schema
|
||||||
|
|
||||||
import MultiServer
|
import MultiServer
|
||||||
from NetUtils import SlotType
|
from NetUtils import GamesPackage, SlotType
|
||||||
from Utils import VersionException, __version__
|
from Utils import VersionException, __version__
|
||||||
from worlds import GamesPackage
|
|
||||||
from worlds.Files import AutoPatchRegister
|
from worlds.Files import AutoPatchRegister
|
||||||
from worlds.AutoWorld import data_package_checksum
|
from worlds.AutoWorld import data_package_checksum
|
||||||
from . import app
|
from . import app
|
||||||
@@ -119,9 +117,9 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
|||||||
# AP Container
|
# AP Container
|
||||||
elif handler:
|
elif handler:
|
||||||
data = zfile.open(file, "r").read()
|
data = zfile.open(file, "r").read()
|
||||||
patch = handler(BytesIO(data))
|
with zipfile.ZipFile(BytesIO(data)) as container:
|
||||||
patch.read()
|
player = json.loads(container.open("archipelago.json").read())["player"]
|
||||||
files[patch.player] = data
|
files[player] = data
|
||||||
|
|
||||||
# Spoiler
|
# Spoiler
|
||||||
elif file.filename.endswith(".txt"):
|
elif file.filename.endswith(".txt"):
|
||||||
@@ -135,11 +133,6 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
|||||||
flash("Could not load multidata. File may be corrupted or incompatible.")
|
flash("Could not load multidata. File may be corrupted or incompatible.")
|
||||||
multidata = None
|
multidata = None
|
||||||
|
|
||||||
# Minecraft
|
|
||||||
elif file.filename.endswith(".apmc"):
|
|
||||||
data = zfile.open(file, "r").read()
|
|
||||||
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
|
|
||||||
files[metadata["player_id"]] = data
|
|
||||||
|
|
||||||
# Factorio
|
# Factorio
|
||||||
elif file.filename.endswith(".zip"):
|
elif file.filename.endswith(".zip"):
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from worlds.tloz.Items import item_game_ids
|
|||||||
from worlds.tloz.Locations import location_ids
|
from worlds.tloz.Locations import location_ids
|
||||||
from worlds.tloz import Items, Locations, Rom
|
from worlds.tloz import Items, Locations, Rom
|
||||||
|
|
||||||
|
from settings import get_settings
|
||||||
|
|
||||||
SYSTEM_MESSAGE_ID = 0
|
SYSTEM_MESSAGE_ID = 0
|
||||||
|
|
||||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_tloz.lua"
|
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_tloz.lua"
|
||||||
@@ -333,6 +335,7 @@ async def nes_sync_task(ctx: ZeldaContext):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
logger.debug("Connection Refused, Trying Again")
|
logger.debug("Connection Refused, Trying Again")
|
||||||
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
||||||
|
await asyncio.sleep(1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
@@ -340,13 +343,12 @@ if __name__ == '__main__':
|
|||||||
# Text Mode to use !hint and such with games that have no text entry
|
# Text Mode to use !hint and such with games that have no text entry
|
||||||
Utils.init_logging("ZeldaClient")
|
Utils.init_logging("ZeldaClient")
|
||||||
|
|
||||||
options = Utils.get_options()
|
DISPLAY_MSGS = get_settings()["tloz_options"]["display_msgs"]
|
||||||
DISPLAY_MSGS = options["tloz_options"]["display_msgs"]
|
|
||||||
|
|
||||||
|
|
||||||
async def run_game(romfile: str) -> None:
|
async def run_game(romfile: str) -> None:
|
||||||
auto_start = typing.cast(typing.Union[bool, str],
|
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:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
|
|||||||
107
data/client.kv
107
data/client.kv
@@ -14,23 +14,71 @@
|
|||||||
salmon: "FA8072" # typically trap item
|
salmon: "FA8072" # typically trap item
|
||||||
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
|
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
|
||||||
orange: "FF7700" # Used for command echo
|
orange: "FF7700" # Used for command echo
|
||||||
<Label>:
|
# KivyMD theming parameters
|
||||||
color: "FFFFFF"
|
theme_style: "Dark" # Light/Dark
|
||||||
<TabbedPanel>:
|
primary_palette: "Lightsteelblue" # Many options
|
||||||
tab_width: root.width / app.tab_count
|
dynamic_scheme_name: "VIBRANT"
|
||||||
|
dynamic_scheme_contrast: 0.0
|
||||||
|
<MDLabel>:
|
||||||
|
color: self.theme_cls.primaryColor
|
||||||
|
<BaseButton>:
|
||||||
|
ripple_color: app.theme_cls.primaryColor
|
||||||
|
ripple_duration_in_fast: 0.2
|
||||||
|
<MDNavigationItemBase>:
|
||||||
|
on_release: app.screens.switch_screens(self)
|
||||||
|
|
||||||
|
MDNavigationItemLabel:
|
||||||
|
text: root.text
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color_active: self.theme_cls.primaryColor
|
||||||
|
text_color_normal: 1, 1, 1, 1
|
||||||
|
# indicator is on icon only for some reason
|
||||||
|
canvas.before:
|
||||||
|
Color:
|
||||||
|
rgba: self.theme_cls.secondaryContainerColor if root.active else self.theme_cls.transparentColor
|
||||||
|
Rectangle:
|
||||||
|
size: root.size
|
||||||
<TooltipLabel>:
|
<TooltipLabel>:
|
||||||
text_size: self.width, None
|
adaptive_height: True
|
||||||
size_hint_y: None
|
theme_font_size: "Custom"
|
||||||
height: self.texture_size[1]
|
font_size: "20dp"
|
||||||
font_size: dp(20)
|
|
||||||
markup: True
|
markup: True
|
||||||
|
halign: "left"
|
||||||
<SelectableLabel>:
|
<SelectableLabel>:
|
||||||
|
size_hint: 1, None
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: 1, 1, 1, 1
|
||||||
canvas.before:
|
canvas.before:
|
||||||
Color:
|
Color:
|
||||||
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1)
|
rgba: (self.theme_cls.primaryColor[0], self.theme_cls.primaryColor[1], self.theme_cls.primaryColor[2], .3) if self.selected else self.theme_cls.surfaceContainerLowestColor
|
||||||
Rectangle:
|
Rectangle:
|
||||||
size: self.size
|
size: self.size
|
||||||
pos: self.pos
|
pos: self.pos
|
||||||
|
<MarkupDropdownItem>
|
||||||
|
orientation: "vertical"
|
||||||
|
|
||||||
|
MDLabel:
|
||||||
|
text: root.text
|
||||||
|
valign: "center"
|
||||||
|
padding_x: "12dp"
|
||||||
|
shorten: True
|
||||||
|
shorten_from: "right"
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
markup: True
|
||||||
|
text_color:
|
||||||
|
app.theme_cls.onSurfaceVariantColor \
|
||||||
|
if not root.text_color else \
|
||||||
|
root.text_color
|
||||||
|
|
||||||
|
MDDivider:
|
||||||
|
md_bg_color:
|
||||||
|
( \
|
||||||
|
app.theme_cls.outlineVariantColor \
|
||||||
|
if not root.divider_color \
|
||||||
|
else root.divider_color \
|
||||||
|
) \
|
||||||
|
if root.divider else \
|
||||||
|
(0, 0, 0, 0)
|
||||||
<UILog>:
|
<UILog>:
|
||||||
messages: 1000 # amount of messages stored in client logs.
|
messages: 1000 # amount of messages stored in client logs.
|
||||||
cols: 1
|
cols: 1
|
||||||
@@ -49,7 +97,7 @@
|
|||||||
<HintLabel>:
|
<HintLabel>:
|
||||||
canvas.before:
|
canvas.before:
|
||||||
Color:
|
Color:
|
||||||
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1) if self.striped else (0.18, 0.18, 0.18, 1)
|
rgba: (.0, 0.9, .1, .3) if self.selected else self.theme_cls.surfaceContainerHighColor if self.striped else self.theme_cls.surfaceContainerLowColor
|
||||||
Rectangle:
|
Rectangle:
|
||||||
size: self.size
|
size: self.size
|
||||||
pos: self.pos
|
pos: self.pos
|
||||||
@@ -126,9 +174,12 @@
|
|||||||
<ToolTip>:
|
<ToolTip>:
|
||||||
size: self.texture_size
|
size: self.texture_size
|
||||||
size_hint: None, None
|
size_hint: None, None
|
||||||
|
theme_font_size: "Custom"
|
||||||
font_size: dp(18)
|
font_size: dp(18)
|
||||||
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
||||||
halign: "left"
|
halign: "left"
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: (1, 1, 1, 1)
|
||||||
canvas.before:
|
canvas.before:
|
||||||
Color:
|
Color:
|
||||||
rgba: 0.2, 0.2, 0.2, 1
|
rgba: 0.2, 0.2, 0.2, 1
|
||||||
@@ -147,8 +198,40 @@
|
|||||||
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
|
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
|
||||||
<ServerToolTip>:
|
<ServerToolTip>:
|
||||||
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
||||||
<AutocompleteHintInput>
|
<AutocompleteHintInput>:
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(30)
|
height: "30dp"
|
||||||
multiline: False
|
multiline: False
|
||||||
write_tab: False
|
write_tab: False
|
||||||
|
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||||
|
<ConnectBarTextInput>:
|
||||||
|
height: "30dp"
|
||||||
|
multiline: False
|
||||||
|
write_tab: False
|
||||||
|
role: "medium"
|
||||||
|
size_hint_y: None
|
||||||
|
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||||
|
<CommandPromptTextInput>:
|
||||||
|
size_hint_y: None
|
||||||
|
height: "30dp"
|
||||||
|
multiline: False
|
||||||
|
write_tab: False
|
||||||
|
pos_hint: {"center_x": 0.5, "center_y": 0.5}
|
||||||
|
<MessageBoxLabel>:
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: 1, 1, 1, 1
|
||||||
|
<MessageBox>:
|
||||||
|
height: self.content.texture_size[1] + 80
|
||||||
|
<ScrollBox>:
|
||||||
|
layout: layout
|
||||||
|
bar_width: "12dp"
|
||||||
|
scroll_wheel_distance: 40
|
||||||
|
do_scroll_x: False
|
||||||
|
scroll_type: ['bars', 'content']
|
||||||
|
|
||||||
|
MDBoxLayout:
|
||||||
|
id: layout
|
||||||
|
orientation: "vertical"
|
||||||
|
spacing: 10
|
||||||
|
size_hint_y: None
|
||||||
|
height: self.minimum_height
|
||||||
|
|||||||
161
data/launcher.kv
Normal file
161
data/launcher.kv
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<LauncherCard>:
|
||||||
|
id: main
|
||||||
|
style: "filled"
|
||||||
|
padding: "4dp"
|
||||||
|
size_hint: 1, None
|
||||||
|
height: "75dp"
|
||||||
|
context_button: context
|
||||||
|
focus_behavior: False
|
||||||
|
|
||||||
|
MDRelativeLayout:
|
||||||
|
ApAsyncImage:
|
||||||
|
source: main.image
|
||||||
|
size: (48, 48)
|
||||||
|
size_hint: None, None
|
||||||
|
pos_hint: {"center_x": 0.1, "center_y": 0.5}
|
||||||
|
|
||||||
|
MDLabel:
|
||||||
|
text: main.component.display_name
|
||||||
|
pos_hint:{"center_x": 0.5, "center_y": 0.75 if main.component.description else 0.65}
|
||||||
|
halign: "center"
|
||||||
|
font_style: "Title"
|
||||||
|
role: "medium"
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: app.theme_cls.primaryColor
|
||||||
|
|
||||||
|
MDLabel:
|
||||||
|
text: main.component.description
|
||||||
|
pos_hint: {"center_x": 0.5, "center_y": 0.35}
|
||||||
|
halign: "center"
|
||||||
|
role: "small"
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: app.theme_cls.primaryColor
|
||||||
|
|
||||||
|
MDIconButton:
|
||||||
|
component: main.component
|
||||||
|
icon: "star" if self.component.display_name in app.favorites else "star-outline"
|
||||||
|
style: "standard"
|
||||||
|
pos_hint:{"center_x": 0.85, "center_y": 0.8}
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: app.theme_cls.primaryColor
|
||||||
|
detect_visible: False
|
||||||
|
on_release: app.set_favorite(self)
|
||||||
|
|
||||||
|
MDIconButton:
|
||||||
|
id: context
|
||||||
|
icon: "menu"
|
||||||
|
style: "standard"
|
||||||
|
pos_hint:{"center_x": 0.95, "center_y": 0.8}
|
||||||
|
theme_text_color: "Custom"
|
||||||
|
text_color: app.theme_cls.primaryColor
|
||||||
|
detect_visible: False
|
||||||
|
|
||||||
|
MDButton:
|
||||||
|
pos_hint:{"center_x": 0.9, "center_y": 0.25}
|
||||||
|
size_hint_y: None
|
||||||
|
height: "25dp"
|
||||||
|
component: main.component
|
||||||
|
on_release: app.component_action(self)
|
||||||
|
detect_visible: False
|
||||||
|
MDButtonText:
|
||||||
|
text: "Open"
|
||||||
|
|
||||||
|
|
||||||
|
#:import Type worlds.LauncherComponents.Type
|
||||||
|
MDFloatLayout:
|
||||||
|
id: top_screen
|
||||||
|
|
||||||
|
MDGridLayout:
|
||||||
|
id: grid
|
||||||
|
cols: 2
|
||||||
|
spacing: "5dp"
|
||||||
|
padding: "10dp"
|
||||||
|
|
||||||
|
MDGridLayout:
|
||||||
|
id: navigation
|
||||||
|
cols: 1
|
||||||
|
size_hint_x: 0.25
|
||||||
|
|
||||||
|
MDButton:
|
||||||
|
id: all
|
||||||
|
style: "text"
|
||||||
|
type: (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "asterisk"
|
||||||
|
MDButtonText:
|
||||||
|
text: "All"
|
||||||
|
MDButton:
|
||||||
|
id: client
|
||||||
|
style: "text"
|
||||||
|
type: (Type.CLIENT, )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "controller"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Client"
|
||||||
|
MDButton:
|
||||||
|
id: Tool
|
||||||
|
style: "text"
|
||||||
|
type: (Type.TOOL, )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "desktop-classic"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Tool"
|
||||||
|
MDButton:
|
||||||
|
id: adjuster
|
||||||
|
style: "text"
|
||||||
|
type: (Type.ADJUSTER, )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "wrench"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Adjuster"
|
||||||
|
MDButton:
|
||||||
|
id: misc
|
||||||
|
style: "text"
|
||||||
|
type: (Type.MISC, )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "dots-horizontal-circle-outline"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Misc"
|
||||||
|
|
||||||
|
MDButton:
|
||||||
|
id: favorites
|
||||||
|
style: "text"
|
||||||
|
type: ("favorites", )
|
||||||
|
on_release: app.filter_clients_by_type(self)
|
||||||
|
|
||||||
|
MDButtonIcon:
|
||||||
|
icon: "star"
|
||||||
|
MDButtonText:
|
||||||
|
text: "Favorites"
|
||||||
|
|
||||||
|
MDNavigationDrawerDivider:
|
||||||
|
|
||||||
|
|
||||||
|
MDGridLayout:
|
||||||
|
id: main_layout
|
||||||
|
cols: 1
|
||||||
|
spacing: "10dp"
|
||||||
|
|
||||||
|
MDTextField:
|
||||||
|
id: search_box
|
||||||
|
mode: "outlined"
|
||||||
|
set_text: app.filter_clients_by_name
|
||||||
|
|
||||||
|
MDTextFieldLeadingIcon:
|
||||||
|
icon: "magnify"
|
||||||
|
|
||||||
|
MDTextFieldHintText:
|
||||||
|
text: "Search"
|
||||||
|
|
||||||
|
ScrollBox:
|
||||||
|
id: button_layout
|
||||||
@@ -365,18 +365,14 @@ request_handlers = {
|
|||||||
["PREFERRED_CORES"] = function (req)
|
["PREFERRED_CORES"] = function (req)
|
||||||
local res = {}
|
local res = {}
|
||||||
local preferred_cores = client.getconfig().PreferredCores
|
local preferred_cores = client.getconfig().PreferredCores
|
||||||
|
local systems_enumerator = preferred_cores.Keys:GetEnumerator()
|
||||||
|
|
||||||
res["type"] = "PREFERRED_CORES_RESPONSE"
|
res["type"] = "PREFERRED_CORES_RESPONSE"
|
||||||
res["value"] = {}
|
res["value"] = {}
|
||||||
res["value"]["NES"] = preferred_cores.NES
|
|
||||||
res["value"]["SNES"] = preferred_cores.SNES
|
while systems_enumerator:MoveNext() do
|
||||||
res["value"]["GB"] = preferred_cores.GB
|
res["value"][systems_enumerator.Current] = preferred_cores[systems_enumerator.Current]
|
||||||
res["value"]["GBC"] = preferred_cores.GBC
|
end
|
||||||
res["value"]["DGB"] = preferred_cores.DGB
|
|
||||||
res["value"]["SGB"] = preferred_cores.SGB
|
|
||||||
res["value"]["PCE"] = preferred_cores.PCE
|
|
||||||
res["value"]["PCECD"] = preferred_cores.PCECD
|
|
||||||
res["value"]["SGX"] = preferred_cores.SGX
|
|
||||||
|
|
||||||
return res
|
return res
|
||||||
end,
|
end,
|
||||||
|
|||||||
@@ -1,462 +0,0 @@
|
|||||||
local socket = require("socket")
|
|
||||||
local json = require('json')
|
|
||||||
local math = require('math')
|
|
||||||
require("common")
|
|
||||||
|
|
||||||
local STATE_OK = "Ok"
|
|
||||||
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
|
|
||||||
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
|
|
||||||
local STATE_UNINITIALIZED = "Uninitialized"
|
|
||||||
|
|
||||||
local ITEM_INDEX = 0x03
|
|
||||||
local WEAPON_INDEX = 0x07
|
|
||||||
local ARMOR_INDEX = 0x0B
|
|
||||||
|
|
||||||
local goldLookup = {
|
|
||||||
[0x16C] = 10,
|
|
||||||
[0x16D] = 20,
|
|
||||||
[0x16E] = 25,
|
|
||||||
[0x16F] = 30,
|
|
||||||
[0x170] = 55,
|
|
||||||
[0x171] = 70,
|
|
||||||
[0x172] = 85,
|
|
||||||
[0x173] = 110,
|
|
||||||
[0x174] = 135,
|
|
||||||
[0x175] = 155,
|
|
||||||
[0x176] = 160,
|
|
||||||
[0x177] = 180,
|
|
||||||
[0x178] = 240,
|
|
||||||
[0x179] = 255,
|
|
||||||
[0x17A] = 260,
|
|
||||||
[0x17B] = 295,
|
|
||||||
[0x17C] = 300,
|
|
||||||
[0x17D] = 315,
|
|
||||||
[0x17E] = 330,
|
|
||||||
[0x17F] = 350,
|
|
||||||
[0x180] = 385,
|
|
||||||
[0x181] = 400,
|
|
||||||
[0x182] = 450,
|
|
||||||
[0x183] = 500,
|
|
||||||
[0x184] = 530,
|
|
||||||
[0x185] = 575,
|
|
||||||
[0x186] = 620,
|
|
||||||
[0x187] = 680,
|
|
||||||
[0x188] = 750,
|
|
||||||
[0x189] = 795,
|
|
||||||
[0x18A] = 880,
|
|
||||||
[0x18B] = 1020,
|
|
||||||
[0x18C] = 1250,
|
|
||||||
[0x18D] = 1455,
|
|
||||||
[0x18E] = 1520,
|
|
||||||
[0x18F] = 1760,
|
|
||||||
[0x190] = 1975,
|
|
||||||
[0x191] = 2000,
|
|
||||||
[0x192] = 2750,
|
|
||||||
[0x193] = 3400,
|
|
||||||
[0x194] = 4150,
|
|
||||||
[0x195] = 5000,
|
|
||||||
[0x196] = 5450,
|
|
||||||
[0x197] = 6400,
|
|
||||||
[0x198] = 6720,
|
|
||||||
[0x199] = 7340,
|
|
||||||
[0x19A] = 7690,
|
|
||||||
[0x19B] = 7900,
|
|
||||||
[0x19C] = 8135,
|
|
||||||
[0x19D] = 9000,
|
|
||||||
[0x19E] = 9300,
|
|
||||||
[0x19F] = 9500,
|
|
||||||
[0x1A0] = 9900,
|
|
||||||
[0x1A1] = 10000,
|
|
||||||
[0x1A2] = 12350,
|
|
||||||
[0x1A3] = 13000,
|
|
||||||
[0x1A4] = 13450,
|
|
||||||
[0x1A5] = 14050,
|
|
||||||
[0x1A6] = 14720,
|
|
||||||
[0x1A7] = 15000,
|
|
||||||
[0x1A8] = 17490,
|
|
||||||
[0x1A9] = 18010,
|
|
||||||
[0x1AA] = 19990,
|
|
||||||
[0x1AB] = 20000,
|
|
||||||
[0x1AC] = 20010,
|
|
||||||
[0x1AD] = 26000,
|
|
||||||
[0x1AE] = 45000,
|
|
||||||
[0x1AF] = 65000
|
|
||||||
}
|
|
||||||
|
|
||||||
local extensionConsumableLookup = {
|
|
||||||
[432] = 0x3C,
|
|
||||||
[436] = 0x3C,
|
|
||||||
[440] = 0x3C,
|
|
||||||
[433] = 0x3D,
|
|
||||||
[437] = 0x3D,
|
|
||||||
[441] = 0x3D,
|
|
||||||
[434] = 0x3E,
|
|
||||||
[438] = 0x3E,
|
|
||||||
[442] = 0x3E,
|
|
||||||
[435] = 0x3F,
|
|
||||||
[439] = 0x3F,
|
|
||||||
[443] = 0x3F
|
|
||||||
}
|
|
||||||
|
|
||||||
local noOverworldItemsLookup = {
|
|
||||||
[499] = 0x2B,
|
|
||||||
[500] = 0x12,
|
|
||||||
}
|
|
||||||
|
|
||||||
local consumableStacks = nil
|
|
||||||
local prevstate = ""
|
|
||||||
local curstate = STATE_UNINITIALIZED
|
|
||||||
local ff1Socket = nil
|
|
||||||
local frame = 0
|
|
||||||
|
|
||||||
local isNesHawk = false
|
|
||||||
|
|
||||||
|
|
||||||
--Sets correct memory access functions based on whether NesHawk or QuickNES is loaded
|
|
||||||
local function defineMemoryFunctions()
|
|
||||||
local memDomain = {}
|
|
||||||
local domains = memory.getmemorydomainlist()
|
|
||||||
if domains[1] == "System Bus" then
|
|
||||||
--NesHawk
|
|
||||||
isNesHawk = true
|
|
||||||
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
|
|
||||||
memDomain["saveram"] = function() memory.usememorydomain("Battery RAM") end
|
|
||||||
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
|
|
||||||
elseif domains[1] == "WRAM" then
|
|
||||||
--QuickNES
|
|
||||||
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
|
|
||||||
memDomain["saveram"] = function() memory.usememorydomain("WRAM") end
|
|
||||||
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
|
|
||||||
end
|
|
||||||
return memDomain
|
|
||||||
end
|
|
||||||
|
|
||||||
local memDomain = defineMemoryFunctions()
|
|
||||||
|
|
||||||
local function StateOKForMainLoop()
|
|
||||||
memDomain.saveram()
|
|
||||||
local A = u8(0x102) -- Party Made
|
|
||||||
local B = u8(0x0FC)
|
|
||||||
local C = u8(0x0A3)
|
|
||||||
return A ~= 0x00 and not (A== 0xF2 and B == 0xF2 and C == 0xF2)
|
|
||||||
end
|
|
||||||
|
|
||||||
function generateLocationChecked()
|
|
||||||
memDomain.saveram()
|
|
||||||
data = uRange(0x01FF, 0x101)
|
|
||||||
data[0] = nil
|
|
||||||
return data
|
|
||||||
end
|
|
||||||
|
|
||||||
function setConsumableStacks()
|
|
||||||
memDomain.rom()
|
|
||||||
consumableStacks = {}
|
|
||||||
-- In order shards, tent, cabin, house, heal, pure, soft, ext1, ext2, ext3, ex4
|
|
||||||
consumableStacks[0x35] = 1
|
|
||||||
consumableStacks[0x36] = u8(0x47400) + 1
|
|
||||||
consumableStacks[0x37] = u8(0x47401) + 1
|
|
||||||
consumableStacks[0x38] = u8(0x47402) + 1
|
|
||||||
consumableStacks[0x39] = u8(0x47403) + 1
|
|
||||||
consumableStacks[0x3A] = u8(0x47404) + 1
|
|
||||||
consumableStacks[0x3B] = u8(0x47405) + 1
|
|
||||||
consumableStacks[0x3C] = u8(0x47406) + 1
|
|
||||||
consumableStacks[0x3D] = u8(0x47407) + 1
|
|
||||||
consumableStacks[0x3E] = u8(0x47408) + 1
|
|
||||||
consumableStacks[0x3F] = u8(0x47409) + 1
|
|
||||||
end
|
|
||||||
|
|
||||||
function getEmptyWeaponSlots()
|
|
||||||
memDomain.saveram()
|
|
||||||
ret = {}
|
|
||||||
count = 1
|
|
||||||
slot1 = uRange(0x118, 0x4)
|
|
||||||
slot2 = uRange(0x158, 0x4)
|
|
||||||
slot3 = uRange(0x198, 0x4)
|
|
||||||
slot4 = uRange(0x1D8, 0x4)
|
|
||||||
for i,v in pairs(slot1) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x118 + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i,v in pairs(slot2) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x158 + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i,v in pairs(slot3) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x198 + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i,v in pairs(slot4) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x1D8 + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return ret
|
|
||||||
end
|
|
||||||
|
|
||||||
function getEmptyArmorSlots()
|
|
||||||
memDomain.saveram()
|
|
||||||
ret = {}
|
|
||||||
count = 1
|
|
||||||
slot1 = uRange(0x11C, 0x4)
|
|
||||||
slot2 = uRange(0x15C, 0x4)
|
|
||||||
slot3 = uRange(0x19C, 0x4)
|
|
||||||
slot4 = uRange(0x1DC, 0x4)
|
|
||||||
for i,v in pairs(slot1) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x11C + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i,v in pairs(slot2) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x15C + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i,v in pairs(slot3) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x19C + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for i,v in pairs(slot4) do
|
|
||||||
if v == 0 then
|
|
||||||
ret[count] = 0x1DC + i
|
|
||||||
count = count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return ret
|
|
||||||
end
|
|
||||||
local function slice (tbl, s, e)
|
|
||||||
local pos, new = 1, {}
|
|
||||||
for i = s + 1, e do
|
|
||||||
new[pos] = tbl[i]
|
|
||||||
pos = pos + 1
|
|
||||||
end
|
|
||||||
return new
|
|
||||||
end
|
|
||||||
function processBlock(block)
|
|
||||||
local msgBlock = block['messages']
|
|
||||||
if msgBlock ~= nil then
|
|
||||||
for i, v in pairs(msgBlock) do
|
|
||||||
if itemMessages[i] == nil then
|
|
||||||
local msg = {TTL=450, message=v, color=0xFFFF0000}
|
|
||||||
itemMessages[i] = msg
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local itemsBlock = block["items"]
|
|
||||||
memDomain.saveram()
|
|
||||||
isInGame = u8(0x102)
|
|
||||||
if itemsBlock ~= nil and isInGame ~= 0x00 then
|
|
||||||
if consumableStacks == nil then
|
|
||||||
setConsumableStacks()
|
|
||||||
end
|
|
||||||
memDomain.saveram()
|
|
||||||
-- print('ITEMBLOCK: ')
|
|
||||||
-- print(itemsBlock)
|
|
||||||
itemIndex = u8(ITEM_INDEX)
|
|
||||||
-- print('ITEMINDEX: '..itemIndex)
|
|
||||||
for i, v in pairs(slice(itemsBlock, itemIndex, #itemsBlock)) do
|
|
||||||
-- Minus the offset and add to the correct domain
|
|
||||||
local memoryLocation = v
|
|
||||||
if v >= 0x100 and v <= 0x114 then
|
|
||||||
-- This is a key item
|
|
||||||
memoryLocation = memoryLocation - 0x0E0
|
|
||||||
wU8(memoryLocation, 0x01)
|
|
||||||
elseif v >= 0x1E0 and v <= 0x1F2 then
|
|
||||||
-- This is a movement item
|
|
||||||
-- Minus Offset (0x100) - movement offset (0xE0)
|
|
||||||
memoryLocation = memoryLocation - 0x1E0
|
|
||||||
-- Canal is a flipped bit
|
|
||||||
if memoryLocation == 0x0C then
|
|
||||||
wU8(memoryLocation, 0x00)
|
|
||||||
else
|
|
||||||
wU8(memoryLocation, 0x01)
|
|
||||||
end
|
|
||||||
elseif v >= 0x1F3 and v <= 0x1F4 then
|
|
||||||
-- NoOverworld special items
|
|
||||||
memoryLocation = noOverworldItemsLookup[v]
|
|
||||||
wU8(memoryLocation, 0x01)
|
|
||||||
elseif v >= 0x16C and v <= 0x1AF then
|
|
||||||
-- This is a gold item
|
|
||||||
amountToAdd = goldLookup[v]
|
|
||||||
biggest = u8(0x01E)
|
|
||||||
medium = u8(0x01D)
|
|
||||||
smallest = u8(0x01C)
|
|
||||||
currentValue = 0x10000 * biggest + 0x100 * medium + smallest
|
|
||||||
newValue = currentValue + amountToAdd
|
|
||||||
newBiggest = math.floor(newValue / 0x10000)
|
|
||||||
newMedium = math.floor(math.fmod(newValue, 0x10000) / 0x100)
|
|
||||||
newSmallest = math.floor(math.fmod(newValue, 0x100))
|
|
||||||
wU8(0x01E, newBiggest)
|
|
||||||
wU8(0x01D, newMedium)
|
|
||||||
wU8(0x01C, newSmallest)
|
|
||||||
elseif v >= 0x115 and v <= 0x11B then
|
|
||||||
-- This is a regular consumable OR a shard
|
|
||||||
-- Minus Offset (0x100) + item offset (0x20)
|
|
||||||
memoryLocation = memoryLocation - 0x0E0
|
|
||||||
currentValue = u8(memoryLocation)
|
|
||||||
amountToAdd = consumableStacks[memoryLocation]
|
|
||||||
if currentValue < 99 then
|
|
||||||
wU8(memoryLocation, currentValue + amountToAdd)
|
|
||||||
end
|
|
||||||
elseif v >= 0x1B0 and v <= 0x1BB then
|
|
||||||
-- This is an extension consumable
|
|
||||||
memoryLocation = extensionConsumableLookup[v]
|
|
||||||
currentValue = u8(memoryLocation)
|
|
||||||
amountToAdd = consumableStacks[memoryLocation]
|
|
||||||
if currentValue < 99 then
|
|
||||||
value = currentValue + amountToAdd
|
|
||||||
if value > 99 then
|
|
||||||
value = 99
|
|
||||||
end
|
|
||||||
wU8(memoryLocation, value)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if #itemsBlock > itemIndex then
|
|
||||||
wU8(ITEM_INDEX, #itemsBlock)
|
|
||||||
end
|
|
||||||
|
|
||||||
memDomain.saveram()
|
|
||||||
weaponIndex = u8(WEAPON_INDEX)
|
|
||||||
emptyWeaponSlots = getEmptyWeaponSlots()
|
|
||||||
lastUsedWeaponIndex = weaponIndex
|
|
||||||
-- print('WEAPON_INDEX: '.. weaponIndex)
|
|
||||||
memDomain.saveram()
|
|
||||||
for i, v in pairs(slice(itemsBlock, weaponIndex, #itemsBlock)) do
|
|
||||||
if v >= 0x11C and v <= 0x143 then
|
|
||||||
-- Minus the offset and add to the correct domain
|
|
||||||
local itemValue = v - 0x11B
|
|
||||||
if #emptyWeaponSlots > 0 then
|
|
||||||
slot = table.remove(emptyWeaponSlots, 1)
|
|
||||||
wU8(slot, itemValue)
|
|
||||||
lastUsedWeaponIndex = weaponIndex + i
|
|
||||||
else
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if lastUsedWeaponIndex ~= weaponIndex then
|
|
||||||
wU8(WEAPON_INDEX, lastUsedWeaponIndex)
|
|
||||||
end
|
|
||||||
memDomain.saveram()
|
|
||||||
armorIndex = u8(ARMOR_INDEX)
|
|
||||||
emptyArmorSlots = getEmptyArmorSlots()
|
|
||||||
lastUsedArmorIndex = armorIndex
|
|
||||||
-- print('ARMOR_INDEX: '.. armorIndex)
|
|
||||||
memDomain.saveram()
|
|
||||||
for i, v in pairs(slice(itemsBlock, armorIndex, #itemsBlock)) do
|
|
||||||
if v >= 0x144 and v <= 0x16B then
|
|
||||||
-- Minus the offset and add to the correct domain
|
|
||||||
local itemValue = v - 0x143
|
|
||||||
if #emptyArmorSlots > 0 then
|
|
||||||
slot = table.remove(emptyArmorSlots, 1)
|
|
||||||
wU8(slot, itemValue)
|
|
||||||
lastUsedArmorIndex = armorIndex + i
|
|
||||||
else
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if lastUsedArmorIndex ~= armorIndex then
|
|
||||||
wU8(ARMOR_INDEX, lastUsedArmorIndex)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function receive()
|
|
||||||
l, e = ff1Socket:receive()
|
|
||||||
if e == 'closed' then
|
|
||||||
if curstate == STATE_OK then
|
|
||||||
print("Connection closed")
|
|
||||||
end
|
|
||||||
curstate = STATE_UNINITIALIZED
|
|
||||||
return
|
|
||||||
elseif e == 'timeout' then
|
|
||||||
print("timeout")
|
|
||||||
return
|
|
||||||
elseif e ~= nil then
|
|
||||||
print(e)
|
|
||||||
curstate = STATE_UNINITIALIZED
|
|
||||||
return
|
|
||||||
end
|
|
||||||
processBlock(json.decode(l))
|
|
||||||
|
|
||||||
-- Determine Message to send back
|
|
||||||
memDomain.rom()
|
|
||||||
local playerName = uRange(0x7BCBF, 0x41)
|
|
||||||
playerName[0] = nil
|
|
||||||
local retTable = {}
|
|
||||||
retTable["playerName"] = playerName
|
|
||||||
if StateOKForMainLoop() then
|
|
||||||
retTable["locations"] = generateLocationChecked()
|
|
||||||
end
|
|
||||||
msg = json.encode(retTable).."\n"
|
|
||||||
local ret, error = ff1Socket:send(msg)
|
|
||||||
if ret == nil then
|
|
||||||
print(error)
|
|
||||||
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
|
|
||||||
curstate = STATE_TENTATIVELY_CONNECTED
|
|
||||||
elseif curstate == STATE_TENTATIVELY_CONNECTED then
|
|
||||||
print("Connected!")
|
|
||||||
itemMessages["(0,0)"] = {TTL=240, message="Connected", color="green"}
|
|
||||||
curstate = STATE_OK
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function main()
|
|
||||||
if not checkBizHawkVersion() then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
server, error = socket.bind('localhost', 52980)
|
|
||||||
|
|
||||||
while true do
|
|
||||||
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
|
|
||||||
frame = frame + 1
|
|
||||||
drawMessages()
|
|
||||||
if not (curstate == prevstate) then
|
|
||||||
-- console.log("Current state: "..curstate)
|
|
||||||
prevstate = curstate
|
|
||||||
end
|
|
||||||
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
|
|
||||||
if (frame % 60 == 0) then
|
|
||||||
gui.drawEllipse(248, 9, 6, 6, "Black", "Blue")
|
|
||||||
receive()
|
|
||||||
else
|
|
||||||
gui.drawEllipse(248, 9, 6, 6, "Black", "Green")
|
|
||||||
end
|
|
||||||
elseif (curstate == STATE_UNINITIALIZED) then
|
|
||||||
gui.drawEllipse(248, 9, 6, 6, "Black", "White")
|
|
||||||
if (frame % 60 == 0) then
|
|
||||||
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
|
|
||||||
|
|
||||||
drawText(5, 8, "Waiting for client", 0xFFFF0000)
|
|
||||||
drawText(5, 32, "Please start FF1Client.exe", 0xFFFF0000)
|
|
||||||
|
|
||||||
-- Advance so the messages are drawn
|
|
||||||
emu.frameadvance()
|
|
||||||
server:settimeout(2)
|
|
||||||
print("Attempting to connect")
|
|
||||||
local client, timeout = server:accept()
|
|
||||||
if timeout == nil then
|
|
||||||
-- print('Initial Connection Made')
|
|
||||||
curstate = STATE_INITIAL_CONNECTION_MADE
|
|
||||||
ff1Socket = client
|
|
||||||
ff1Socket:settimeout(0)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
emu.frameadvance()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
main()
|
|
||||||
@@ -477,7 +477,7 @@ function main()
|
|||||||
elseif (curstate == STATE_UNINITIALIZED) then
|
elseif (curstate == STATE_UNINITIALIZED) then
|
||||||
-- If we're uninitialized, attempt to make the connection.
|
-- If we're uninitialized, attempt to make the connection.
|
||||||
if (frame % 120 == 0) then
|
if (frame % 120 == 0) then
|
||||||
server:settimeout(2)
|
server:settimeout(120)
|
||||||
local client, timeout = server:accept()
|
local client, timeout = server:accept()
|
||||||
if timeout == nil then
|
if timeout == nil then
|
||||||
print('Initial Connection Made')
|
print('Initial Connection Made')
|
||||||
|
|||||||
BIN
data/mcicon.ico
BIN
data/mcicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -46,15 +46,16 @@ requires:
|
|||||||
|
|
||||||
{{ yaml_dump(game) }}:
|
{{ yaml_dump(game) }}:
|
||||||
{%- for group_name, group_options in option_groups.items() %}
|
{%- 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() %}
|
{%- for option_key, option in group_options.items() %}
|
||||||
{{ option_key }}:
|
{{ option_key }}:
|
||||||
{%- if option.__doc__ %}
|
{%- if option.__doc__ %}
|
||||||
# {{ option.__doc__
|
# {{ cleandoc(option.__doc__)
|
||||||
| trim
|
| trim
|
||||||
| replace('\n\n', '\n \n')
|
| replace('\n', '\n# ')
|
||||||
| replace('\n ', '\n# ')
|
|
||||||
| indent(4, first=False)
|
| indent(4, first=False)
|
||||||
}}
|
}}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
|||||||
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
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user