Compare commits
553 Commits
allow_coll
...
0.4.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aed2be93b | ||
|
|
974bab2b24 | ||
|
|
98d61b32af | ||
|
|
4141a50d8c | ||
|
|
93c18cd9a7 | ||
|
|
5af47425b0 | ||
|
|
b41a1e69b4 | ||
|
|
124113f3d3 | ||
|
|
2b69820619 | ||
|
|
f147f9e5a0 | ||
|
|
db7c0c9db9 | ||
|
|
b40fba0840 | ||
|
|
ea799c494e | ||
|
|
b4b8426def | ||
|
|
39a50da55c | ||
|
|
9931605f94 | ||
|
|
8834ba88aa | ||
|
|
5e46967b7d | ||
|
|
638d6807db | ||
|
|
d471dcc067 | ||
|
|
4a27fae1ab | ||
|
|
794959e182 | ||
|
|
aff852fb45 | ||
|
|
a0eea3a650 | ||
|
|
0012584e51 | ||
|
|
6e02a4ca3c | ||
|
|
2ef05a1799 | ||
|
|
fa2891f785 | ||
|
|
d5d13a6d4d | ||
|
|
b24037e9d9 | ||
|
|
6d6de4a98e | ||
|
|
0e7c7bd1bf | ||
|
|
9312f14ffb | ||
|
|
ce8f07b347 | ||
|
|
cff6c7c4da | ||
|
|
f9120c620f | ||
|
|
44f1a93d31 | ||
|
|
6d61eae522 | ||
|
|
f05a9ecd2f | ||
|
|
648d682add | ||
|
|
47cf3e06c0 | ||
|
|
fdac50523b | ||
|
|
7522a32ad6 | ||
|
|
8ee743ac8a | ||
|
|
c3cfbf8e1c | ||
|
|
1756a30acc | ||
|
|
57c13ff273 | ||
|
|
3d9837678c | ||
|
|
3e95ccd06c | ||
|
|
0e21a3e121 | ||
|
|
5eef7a34d3 | ||
|
|
6c844750ae | ||
|
|
8649b15787 | ||
|
|
fbd64651e4 | ||
|
|
e01eb4e00c | ||
|
|
72b44be41c | ||
|
|
2bdb1b2029 | ||
|
|
bf685dc850 | ||
|
|
faf4887616 | ||
|
|
a1418ccb66 | ||
|
|
29f8053d6e | ||
|
|
f6dafa2b56 | ||
|
|
2b9e8fa273 | ||
|
|
5368451867 | ||
|
|
77a349c1c6 | ||
|
|
c4a3204af7 | ||
|
|
9323f7d892 | ||
|
|
30e747bb4c | ||
|
|
9d29c6d301 | ||
|
|
aa19a79d26 | ||
|
|
5a34471266 | ||
|
|
ae96010ff1 | ||
|
|
944fe6cb8c | ||
|
|
21baa302d4 | ||
|
|
9ad0032eb4 | ||
|
|
09fd65209c | ||
|
|
d8d9a49564 | ||
|
|
41a34b140c | ||
|
|
b235ba2c52 | ||
|
|
7ce9f20bc7 | ||
|
|
6c7a7d2be5 | ||
|
|
8af4fda7b6 | ||
|
|
e30f364bbd | ||
|
|
be07634b15 | ||
|
|
5cd837256f | ||
|
|
26b4ff1df2 | ||
|
|
61ff94259a | ||
|
|
4cb4c254dc | ||
|
|
3a4b157363 | ||
|
|
7a494d637b | ||
|
|
ca06a4b836 | ||
|
|
3643b1de2c | ||
|
|
d0c6eaf239 | ||
|
|
64d1722acd | ||
|
|
01e8e9576c | ||
|
|
d5514c4635 | ||
|
|
d5474128e3 | ||
|
|
8d6b2dfc9c | ||
|
|
c9404d75b0 | ||
|
|
eb50e0781e | ||
|
|
6864f28f3e | ||
|
|
6befc91773 | ||
|
|
1d6a2bff4f | ||
|
|
898558b121 | ||
|
|
a9fb7e2ace | ||
|
|
f29d5c8cae | ||
|
|
cacfd4ffae | ||
|
|
62315e304a | ||
|
|
cc39eec646 | ||
|
|
de1ec4a18f | ||
|
|
40c9287eba | ||
|
|
5869f78ea7 | ||
|
|
6c908de13f | ||
|
|
29d67ac456 | ||
|
|
6d93a6234e | ||
|
|
b579dbfdf8 | ||
|
|
6ad33bb16e | ||
|
|
7b8f8918fc | ||
|
|
a90825eac3 | ||
|
|
280ebf9c34 | ||
|
|
672a97c9ae | ||
|
|
b684ba4822 | ||
|
|
0d28eeb3c5 | ||
|
|
cf37a69e53 | ||
|
|
a99a407c41 | ||
|
|
8f447487fb | ||
|
|
eb8855afb9 | ||
|
|
09c3a99be8 | ||
|
|
3bf86cd8f0 | ||
|
|
2333ddeaf7 | ||
|
|
0e8ad7b9bc | ||
|
|
9d1a31004f | ||
|
|
f2d0d1e895 | ||
|
|
6a96f33ad2 | ||
|
|
bb069443a4 | ||
|
|
fa3d69cf48 | ||
|
|
6107749cbe | ||
|
|
60289666dc | ||
|
|
5b8c3425c8 | ||
|
|
85b92e2696 | ||
|
|
cf8ac49f76 | ||
|
|
d9594b049c | ||
|
|
caa8d478f5 | ||
|
|
7279de0605 | ||
|
|
d49860fbeb | ||
|
|
591661ca79 | ||
|
|
e1374492de | ||
|
|
5843f71447 | ||
|
|
9b1de8fea8 | ||
|
|
86a55c7837 | ||
|
|
8405b35a94 | ||
|
|
889a4f4db9 | ||
|
|
191dcb505c | ||
|
|
ecb1e0b74b | ||
|
|
f8e2d7f503 | ||
|
|
8015734fcf | ||
|
|
21228f9c63 | ||
|
|
57c1bc800c | ||
|
|
7f180a6d5a | ||
|
|
9839164817 | ||
|
|
3c1950dd40 | ||
|
|
e8bf471dcd | ||
|
|
210d6f81eb | ||
|
|
6797216eb8 | ||
|
|
1e72851b28 | ||
|
|
75463193ab | ||
|
|
257774c31b | ||
|
|
ca46a64abc | ||
|
|
1a29caffcb | ||
|
|
8fd805235d | ||
|
|
62657df3fb | ||
|
|
1f6db12797 | ||
|
|
18c9779815 | ||
|
|
09f4b7ec38 | ||
|
|
d14131c3be | ||
|
|
8360435607 | ||
|
|
83387da6a4 | ||
|
|
f318ca8886 | ||
|
|
1630529d58 | ||
|
|
60b8daa3af | ||
|
|
a77739ba18 | ||
|
|
60586aa284 | ||
|
|
f1d09d2282 | ||
|
|
48746f6c62 | ||
|
|
8c5688e5e2 | ||
|
|
bad79ee11a | ||
|
|
afed1dc558 | ||
|
|
8df08b53d9 | ||
|
|
dfe08298ef | ||
|
|
48ffad867a | ||
|
|
a88e75f3a1 | ||
|
|
087cc334f4 | ||
|
|
11278d0e61 | ||
|
|
bff2b80acf | ||
|
|
5b606e53fc | ||
|
|
9af56ec0dd | ||
|
|
ab22b11bac | ||
|
|
07d74ac186 | ||
|
|
36474c3ccc | ||
|
|
736945658a | ||
|
|
cfe14aec76 | ||
|
|
feaa30d808 | ||
|
|
1338d7a968 | ||
|
|
f2117be7d9 | ||
|
|
5f2c226b43 | ||
|
|
81b956408e | ||
|
|
354a182859 | ||
|
|
827444f5a4 | ||
|
|
d8a8997684 | ||
|
|
e920692ec3 | ||
|
|
6fd16ecced | ||
|
|
50537a9161 | ||
|
|
cbb7616f03 | ||
|
|
85a2193f35 | ||
|
|
857364fa78 | ||
|
|
153125a5ea | ||
|
|
b6e78bd1a3 | ||
|
|
d35d3b629e | ||
|
|
532c4c068f | ||
|
|
b077b2aeef | ||
|
|
e9e18054cf | ||
|
|
d94bee20d0 | ||
|
|
c321c5d256 | ||
|
|
ee40312384 | ||
|
|
a6ba185c55 | ||
|
|
6a88d5aa79 | ||
|
|
4a60d8a4c1 | ||
|
|
9b15278de8 | ||
|
|
fa3c132304 | ||
|
|
6226713c4d | ||
|
|
b56da79890 | ||
|
|
1d6345d3a2 | ||
|
|
51a639ceaf | ||
|
|
7ecb1e6d6c | ||
|
|
c9fb443c64 | ||
|
|
325299286b | ||
|
|
776b5fab7c | ||
|
|
18e0d25051 | ||
|
|
dfb3df4a8f | ||
|
|
d0db728850 | ||
|
|
77b0852dca | ||
|
|
3fba94f000 | ||
|
|
85582b9458 | ||
|
|
122d404145 | ||
|
|
07e3fbe845 | ||
|
|
76cace725b | ||
|
|
99656bf059 | ||
|
|
332eab9569 | ||
|
|
8c2584f872 | ||
|
|
1ced726d31 | ||
|
|
d51e0ec0ab | ||
|
|
36b5b1207c | ||
|
|
a4e485e297 | ||
|
|
a7bc8846cd | ||
|
|
125ee8b198 | ||
|
|
553fe0be19 | ||
|
|
71bfb6babd | ||
|
|
1698c17caa | ||
|
|
751e5cec63 | ||
|
|
dc46e96e3f | ||
|
|
0934e5c711 | ||
|
|
aa8ffa247d | ||
|
|
a45e8730cb | ||
|
|
46f2f3d7cd | ||
|
|
a96ff8de16 | ||
|
|
f3e2e429b8 | ||
|
|
46b13e0b53 | ||
|
|
7a4e903906 | ||
|
|
f1ccf1b663 | ||
|
|
ec0822c5eb | ||
|
|
78b981228a | ||
|
|
f3c788d0cc | ||
|
|
59ad9e97e5 | ||
|
|
abd8eaf36e | ||
|
|
f36468fc25 | ||
|
|
a939f50480 | ||
|
|
b04b105bd8 | ||
|
|
845502ad39 | ||
|
|
afe9e12ef4 | ||
|
|
a75159b57e | ||
|
|
61fc80505e | ||
|
|
25f285b242 | ||
|
|
c4e28a8736 | ||
|
|
422ccdaa4c | ||
|
|
1e7c650159 | ||
|
|
ab64173600 | ||
|
|
36499b8983 | ||
|
|
923ff033b1 | ||
|
|
599d0ac81b | ||
|
|
ce2433b247 | ||
|
|
f6cb90daf9 | ||
|
|
54b200451d | ||
|
|
b98080afee | ||
|
|
5401e485aa | ||
|
|
58cf9783eb | ||
|
|
fad0fe16f4 | ||
|
|
c2884e9eb0 | ||
|
|
1809823308 | ||
|
|
df7462efcc | ||
|
|
00e3c44400 | ||
|
|
abf4b3bcbc | ||
|
|
c9f217943e | ||
|
|
e9f8b1ed28 | ||
|
|
c46d8afcfa | ||
|
|
f4d9c294a3 | ||
|
|
42d8fb8409 | ||
|
|
127d4812b5 | ||
|
|
527f30d91a | ||
|
|
1d565b9aaf | ||
|
|
6814bc158a | ||
|
|
e80f3206b6 | ||
|
|
54ea917c48 | ||
|
|
5e9bf4b007 | ||
|
|
c8453035da | ||
|
|
a2ddd5c9e8 | ||
|
|
97ba631b80 | ||
|
|
be4c597c8d | ||
|
|
324d3cf042 | ||
|
|
b1c5456d18 | ||
|
|
f474b81f40 | ||
|
|
5255bc5cd8 | ||
|
|
18127a75f5 | ||
|
|
899de428df | ||
|
|
f401702e7c | ||
|
|
68bfe1705d | ||
|
|
98b0bf7456 | ||
|
|
7674e62ba7 | ||
|
|
0b33c25b39 | ||
|
|
62f4e62d71 | ||
|
|
48add4687c | ||
|
|
cc08e853a0 | ||
|
|
7e3fa5058d | ||
|
|
c74577d708 | ||
|
|
a8b76b1310 | ||
|
|
c8ebad1dfe | ||
|
|
d3447a3983 | ||
|
|
11b2b5ed2f | ||
|
|
a0464ecea1 | ||
|
|
97fd78ba1b | ||
|
|
a60f370224 | ||
|
|
0363630f61 | ||
|
|
39c7c7291e | ||
|
|
a368520200 | ||
|
|
5d25f908a4 | ||
|
|
9edab76567 | ||
|
|
91b60f2e21 | ||
|
|
41b59488e3 | ||
|
|
42da24cb5e | ||
|
|
28c5e9ee65 | ||
|
|
b55174ccdf | ||
|
|
7bcf299412 | ||
|
|
a7816d186f | ||
|
|
9d40471dee | ||
|
|
b704070de5 | ||
|
|
6c459066a7 | ||
|
|
4c3eaf2996 | ||
|
|
bb56f7b400 | ||
|
|
22ed7ff9c3 | ||
|
|
173513c9f4 | ||
|
|
c0cf35edda | ||
|
|
dcc628f878 | ||
|
|
b950af09a6 | ||
|
|
58aea7ca58 | ||
|
|
06a25a903e | ||
|
|
62a265cc31 | ||
|
|
67c3076572 | ||
|
|
ab5cb7adad | ||
|
|
5e84f91d2f | ||
|
|
a7a17a5a4d | ||
|
|
a6ea3e1953 | ||
|
|
0515acc8fe | ||
|
|
bf5c1cbbbf | ||
|
|
c8fb46a5e6 | ||
|
|
a38a2903d5 | ||
|
|
4ef7e43521 | ||
|
|
e1f17fadfc | ||
|
|
4dc934729d | ||
|
|
722757e18a | ||
|
|
0ca3c5e6a2 | ||
|
|
664bbd86bb | ||
|
|
7a9d4272be | ||
|
|
7559adbb14 | ||
|
|
1a7bc4ffd4 | ||
|
|
be74a4a71a | ||
|
|
cb634fa8d4 | ||
|
|
f6758524d5 | ||
|
|
f395a6d184 | ||
|
|
ea03c90152 | ||
|
|
50d9ab041a | ||
|
|
acd3cb45bf | ||
|
|
8a78062825 | ||
|
|
599cd2c82e | ||
|
|
89ec31708e | ||
|
|
ef211da27f | ||
|
|
0122eb38ab | ||
|
|
3d8bc0bb67 | ||
|
|
d85c13ef0e | ||
|
|
27cb93d319 | ||
|
|
b0e8c8db6b | ||
|
|
5a7d20d393 | ||
|
|
808203a50f | ||
|
|
d8f79b4a42 | ||
|
|
02ef6cee47 | ||
|
|
e716b50f8c | ||
|
|
3fdf07677c | ||
|
|
d3baca9251 | ||
|
|
f52ca2571f | ||
|
|
469807ba01 | ||
|
|
8ada91939c | ||
|
|
054d14baa4 | ||
|
|
f0324e60f8 | ||
|
|
70ff19ac8c | ||
|
|
b02b329181 | ||
|
|
8b7ffaf671 | ||
|
|
c711d803f8 | ||
|
|
3c3954f5e8 | ||
|
|
05d398a51d | ||
|
|
5eadbc9840 | ||
|
|
0c1e3097c3 | ||
|
|
cdf7ca1dcc | ||
|
|
77fbd0eb2b | ||
|
|
c7284f90d9 | ||
|
|
8d559daa35 | ||
|
|
e49ffc64f2 | ||
|
|
94a02510c0 | ||
|
|
9d73988030 | ||
|
|
81411a191c | ||
|
|
6059b5ef66 | ||
|
|
0bc5a3bc8d | ||
|
|
11fdb29357 | ||
|
|
bbef7a4cbc | ||
|
|
8e7bbb4ea8 | ||
|
|
6628e8c85d | ||
|
|
84402a1b55 | ||
|
|
f4035b8621 | ||
|
|
bbf8546867 | ||
|
|
67a22b8b43 | ||
|
|
8e6ec85532 | ||
|
|
8fc50510a0 | ||
|
|
aa6ad5d34f | ||
|
|
ccb89dd65c | ||
|
|
a86c0aa37d | ||
|
|
ece6598b09 | ||
|
|
eef8f7af1a | ||
|
|
c626618221 | ||
|
|
47989325f8 | ||
|
|
815e7e6b0a | ||
|
|
a61a1f58c6 | ||
|
|
4c24872264 | ||
|
|
e778e49574 | ||
|
|
25f7413881 | ||
|
|
397ce8343e | ||
|
|
37fdc00517 | ||
|
|
03aa9b3604 | ||
|
|
8f52e4654f | ||
|
|
a86fd37860 | ||
|
|
eb503adb13 | ||
|
|
cbf72becc1 | ||
|
|
cdd460ae15 | ||
|
|
ffd968d89d | ||
|
|
8d73746d5b | ||
|
|
5ed56db48a | ||
|
|
5f447f4e6b | ||
|
|
f015cf4298 | ||
|
|
e43bb99622 | ||
|
|
34de5a57af | ||
|
|
510a460d84 | ||
|
|
6e271b643d | ||
|
|
8971340a66 | ||
|
|
30cfd3186c | ||
|
|
1dc4e2b44b | ||
|
|
d5b4a91a13 | ||
|
|
bf5282dfa8 | ||
|
|
4eea91daab | ||
|
|
20e80d06cf | ||
|
|
59b78528a9 | ||
|
|
cd4fd18706 | ||
|
|
af44c1ba3d | ||
|
|
3ef0a56ec2 | ||
|
|
4ff282a384 | ||
|
|
f3dad894ec | ||
|
|
a5373e3672 | ||
|
|
639606e0be | ||
|
|
bb79073ce7 | ||
|
|
53b3cd029e | ||
|
|
99bd525c8e | ||
|
|
d14ab97849 | ||
|
|
f50e85b401 | ||
|
|
b64565594a | ||
|
|
ae7dad8bf9 | ||
|
|
b7c74919b7 | ||
|
|
a7f7f91aaf | ||
|
|
e62f989ce8 | ||
|
|
21c6c28755 | ||
|
|
f0403b9c9d | ||
|
|
f09f3663d6 | ||
|
|
b5bd93c420 | ||
|
|
90813c0f4b | ||
|
|
e2c4293a6d | ||
|
|
963c33c02a | ||
|
|
7d603e7d8d | ||
|
|
f2e1495d39 | ||
|
|
7927b2ee25 | ||
|
|
4f2b13a674 | ||
|
|
ffd7d5da74 | ||
|
|
67eb370200 | ||
|
|
4456e36fbb | ||
|
|
7fd9e71b3c | ||
|
|
f4a68f1c3d | ||
|
|
754a57cf69 | ||
|
|
384577e421 | ||
|
|
0ed3865c30 | ||
|
|
77b2ed54a6 | ||
|
|
0386d9f6d2 | ||
|
|
7e52b6d8bb | ||
|
|
03cf525b2c | ||
|
|
e1f46d623c | ||
|
|
5bb6ff0ce0 | ||
|
|
256f493ada | ||
|
|
3ec2d45f4f | ||
|
|
b3895750ab | ||
|
|
7591404151 | ||
|
|
d48e1e447f | ||
|
|
206f8cf5ed | ||
|
|
0c6b1827fe | ||
|
|
017f91c1b5 | ||
|
|
95b01def6b | ||
|
|
5977e401d5 | ||
|
|
21a3c74783 | ||
|
|
2fb9176511 | ||
|
|
1c69fb3c3c | ||
|
|
91502505a1 | ||
|
|
01c13ca243 | ||
|
|
c2a8b842de | ||
|
|
856efebc39 | ||
|
|
5a4203649d | ||
|
|
ddb764a9b6 | ||
|
|
9f65f22fac | ||
|
|
b7ff9b69ba | ||
|
|
012e6ba24c | ||
|
|
cd9d0bebc8 | ||
|
|
3fa6588637 | ||
|
|
e6d16c905c | ||
|
|
958829d491 | ||
|
|
9ee37b0ec5 | ||
|
|
81a239325d | ||
|
|
67bf12369a | ||
|
|
d4b793902f | ||
|
|
6671b21a86 | ||
|
|
6d13dc4944 | ||
|
|
ff9f563d4a | ||
|
|
d825576f12 | ||
|
|
5d6184f1fd |
80
.github/workflows/analyze-modified-files.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
name: Analyze modified files
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.py"
|
||||
push:
|
||||
paths:
|
||||
- "**.py"
|
||||
|
||||
env:
|
||||
BASE: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD: ${{ github.event.pull_request.head.sha }}
|
||||
BEFORE: ${{ github.event.before }}
|
||||
AFTER: ${{ github.event.after }}
|
||||
|
||||
jobs:
|
||||
flake8-or-mypy:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
task: [flake8, mypy]
|
||||
|
||||
name: ${{ matrix.task }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: "Determine modified files (pull_request)"
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
git fetch origin $BASE $HEAD
|
||||
DIFF=$(git diff --diff-filter=d --name-only $BASE...$HEAD -- "*.py")
|
||||
echo "modified files:"
|
||||
echo "$DIFF"
|
||||
echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV
|
||||
|
||||
- name: "Determine modified files (push)"
|
||||
if: github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000'
|
||||
run: |
|
||||
git fetch origin $BEFORE $AFTER
|
||||
DIFF=$(git diff --diff-filter=d --name-only $BEFORE..$AFTER -- "*.py")
|
||||
echo "modified files:"
|
||||
echo "$DIFF"
|
||||
echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV
|
||||
|
||||
- name: "Treat all files as modified (new branch)"
|
||||
if: github.event_name == 'push' && github.event.before == '0000000000000000000000000000000000000000'
|
||||
run: |
|
||||
echo "diff=." >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
if: env.diff != ''
|
||||
with:
|
||||
python-version: 3.8
|
||||
|
||||
- name: "Install dependencies"
|
||||
if: env.diff != ''
|
||||
run: |
|
||||
python -m pip install --upgrade pip ${{ matrix.task }}
|
||||
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
||||
|
||||
- name: "flake8: Stop the build if there are Python syntax errors or undefined names"
|
||||
continue-on-error: false
|
||||
if: env.diff != '' && matrix.task == 'flake8'
|
||||
run: |
|
||||
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
|
||||
|
||||
- name: "flake8: Lint modified files"
|
||||
continue-on-error: true
|
||||
if: env.diff != '' && matrix.task == 'flake8'
|
||||
run: |
|
||||
flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
|
||||
|
||||
- name: "mypy: Type check modified files"
|
||||
continue-on-error: true
|
||||
if: env.diff != '' && matrix.task == 'mypy'
|
||||
run: |
|
||||
mypy --follow-imports=silent --install-types --non-interactive --strict ${{ env.diff }}
|
||||
23
.github/workflows/build.yml
vendored
@@ -36,15 +36,15 @@ jobs:
|
||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||
- name: Build
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools
|
||||
pip install -r requirements.txt
|
||||
python -m pip install --upgrade pip
|
||||
python setup.py build_exe --yes
|
||||
$NAME="$(ls build)".Split('.',2)[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
|
||||
Rename-Item "exe.$NAME" Archipelago
|
||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||
- name: Store 7z
|
||||
uses: actions/upload-artifact@v3
|
||||
@@ -53,8 +53,8 @@ jobs:
|
||||
path: dist/${{ env.ZIP_NAME }}
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
|
||||
build-ubuntu1804:
|
||||
runs-on: ubuntu-18.04
|
||||
build-ubuntu2004:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
# - copy code below to release.yml -
|
||||
- uses: actions/checkout@v3
|
||||
@@ -66,10 +66,10 @@ jobs:
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
python-version: '3.11'
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
@@ -85,8 +85,7 @@ jobs:
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer
|
||||
pip install -r requirements.txt
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
||||
python setup.py build_exe --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
@@ -96,6 +95,10 @@ jobs:
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - copy code above to release.yml -
|
||||
- name: Build Again
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
python setup.py build_exe --yes
|
||||
- name: Store AppImage
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
||||
35
.github/workflows/lint.yml
vendored
@@ -1,35 +0,0 @@
|
||||
# This workflow will install Python dependencies, run tests and lint with a single version of Python
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
|
||||
|
||||
name: lint
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.py'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.py'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip wheel
|
||||
pip install flake8 pytest pytest-subtests
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
13
.github/workflows/release.yml
vendored
@@ -29,8 +29,8 @@ jobs:
|
||||
# build-release-windows: # this is done by hand because of signing
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-release-ubuntu1804:
|
||||
runs-on: ubuntu-18.04
|
||||
build-release-ubuntu2004:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
@@ -44,10 +44,10 @@ jobs:
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
python-version: '3.11'
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.9" >> $GITHUB_ENV
|
||||
echo "PYTHON=python3.11" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
@@ -63,9 +63,8 @@ jobs:
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes bdist_appimage --yes
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
||||
python setup.py build_exe --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
echo -e "setup.py dist output:\n `ls dist`"
|
||||
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
|
||||
|
||||
10
.github/workflows/unittests.yml
vendored
@@ -36,12 +36,13 @@ jobs:
|
||||
- {version: '3.8'}
|
||||
- {version: '3.9'}
|
||||
- {version: '3.10'}
|
||||
- {version: '3.11'}
|
||||
include:
|
||||
- python: {version: '3.8'} # win7 compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.10'} # current
|
||||
- python: {version: '3.11'} # current
|
||||
os: windows-latest
|
||||
- python: {version: '3.10'} # current
|
||||
- python: {version: '3.11'} # current
|
||||
os: macos-latest
|
||||
|
||||
steps:
|
||||
@@ -52,9 +53,10 @@ jobs:
|
||||
python-version: ${{ matrix.python.version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip wheel
|
||||
pip install flake8 pytest pytest-subtests
|
||||
python -m pip install --upgrade pip
|
||||
pip install pytest pytest-subtests
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
python Launcher.py --update_settings # make sure host.yaml exists for tests
|
||||
- name: Unittests
|
||||
run: |
|
||||
pytest
|
||||
|
||||
11
.gitignore
vendored
@@ -26,7 +26,9 @@
|
||||
*multisave
|
||||
*.archipelago
|
||||
*.apsave
|
||||
*.BIN
|
||||
|
||||
setups
|
||||
build
|
||||
bundle/components.wxs
|
||||
dist
|
||||
@@ -35,6 +37,7 @@ README.html
|
||||
EnemizerCLI/
|
||||
/Players/
|
||||
/SNI/
|
||||
/host.yaml
|
||||
/options.yaml
|
||||
/config.yaml
|
||||
/logs/
|
||||
@@ -52,6 +55,7 @@ Output Logs/
|
||||
/setup.ini
|
||||
/installdelete.iss
|
||||
/data/user.kv
|
||||
/datapackage
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -165,6 +169,10 @@ dmypy.json
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# Cython intermediates
|
||||
_speedups.cpp
|
||||
_speedups.html
|
||||
|
||||
# minecraft server stuff
|
||||
jdk*/
|
||||
minecraft*/
|
||||
@@ -174,6 +182,9 @@ minecraft_versions.json
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
#undertale stuff
|
||||
/Undertale/
|
||||
|
||||
# OS General Files
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
|
||||
516
AdventureClient.py
Normal file
@@ -0,0 +1,516 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import bsdiff4
|
||||
import subprocess
|
||||
import zipfile
|
||||
from asyncio import StreamReader, StreamWriter, CancelledError
|
||||
from typing import List
|
||||
|
||||
|
||||
import Utils
|
||||
from NetUtils import ClientStatus
|
||||
from Utils import async_start
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||
get_base_parser
|
||||
from worlds.adventure import AdventureDeltaPatch
|
||||
|
||||
from worlds.adventure.Locations import base_location_id
|
||||
from worlds.adventure.Rom import AdventureForeignItemInfo, AdventureAutoCollectLocation, BatNoTouchLocation
|
||||
from worlds.adventure.Items import base_adventure_item_id, standard_item_max, item_table
|
||||
from worlds.adventure.Offsets import static_item_element_size, connector_port_offset
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = \
|
||||
"Connection timing out. Please restart your emulator, then restart connector_adventure.lua"
|
||||
CONNECTION_REFUSED_STATUS = \
|
||||
"Connection Refused. Please start your emulator and make sure connector_adventure.lua is running"
|
||||
CONNECTION_RESET_STATUS = \
|
||||
"Connection was reset. Please restart your emulator, then restart connector_adventure.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
SCRIPT_VERSION = 1
|
||||
|
||||
|
||||
class AdventureCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_2600(self):
|
||||
"""Check 2600 Connection State"""
|
||||
if isinstance(self.ctx, AdventureContext):
|
||||
logger.info(f"2600 Status: {self.ctx.atari_status}")
|
||||
|
||||
def _cmd_aconnect(self):
|
||||
"""Discard current atari 2600 connection state"""
|
||||
if isinstance(self.ctx, AdventureContext):
|
||||
self.ctx.atari_sync_task.cancel()
|
||||
|
||||
|
||||
class AdventureContext(CommonContext):
|
||||
command_processor = AdventureCommandProcessor
|
||||
game = 'Adventure'
|
||||
lua_connector_port: int = 17242
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.freeincarnates_used: int = -1
|
||||
self.freeincarnate_pending: int = 0
|
||||
self.foreign_items: [AdventureForeignItemInfo] = []
|
||||
self.autocollect_items: [AdventureAutoCollectLocation] = []
|
||||
self.atari_streams: (StreamReader, StreamWriter) = None
|
||||
self.atari_sync_task = None
|
||||
self.messages = {}
|
||||
self.locations_array = None
|
||||
self.atari_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.display_msgs = True
|
||||
self.deathlink_pending = False
|
||||
self.set_deathlink = False
|
||||
self.client_compatibility_mode = 0
|
||||
self.items_handling = 0b111
|
||||
self.checked_locations_sent: bool = False
|
||||
self.port_offset = 0
|
||||
self.bat_no_touch_locations: [BatNoTouchLocation] = []
|
||||
self.local_item_locations = {}
|
||||
self.dragon_speed_info = {}
|
||||
|
||||
options = Utils.get_options()
|
||||
self.display_msgs = options["adventure_options"]["display_msgs"]
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(AdventureContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.auth = self.player_name
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to adventure_connector to get Player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
if self.display_msgs:
|
||||
self.messages[(time.time(), msg_id)] = msg
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.locations_array = None
|
||||
if Utils.get_options()["adventure_options"].get("death_link", False):
|
||||
self.set_deathlink = True
|
||||
async_start(self.get_freeincarnates_used())
|
||||
elif cmd == "RoomInfo":
|
||||
self.seed_name = args['seed_name']
|
||||
elif cmd == 'Print':
|
||||
msg = args['text']
|
||||
if ': !' not in msg:
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == "ReceivedItems":
|
||||
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == "Retrieved":
|
||||
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
|
||||
if self.freeincarnates_used is None:
|
||||
self.freeincarnates_used = 0
|
||||
self.freeincarnates_used += self.freeincarnate_pending
|
||||
self.send_pending_freeincarnates()
|
||||
elif cmd == "SetReply":
|
||||
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
|
||||
self.freeincarnates_used = args["value"]
|
||||
if self.freeincarnates_used is None:
|
||||
self.freeincarnates_used = 0
|
||||
self.freeincarnates_used += self.freeincarnate_pending
|
||||
self.send_pending_freeincarnates()
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
self.deathlink_pending = True
|
||||
super().on_deathlink(data)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class AdventureManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Adventure Client"
|
||||
|
||||
self.ui = AdventureManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
async def get_freeincarnates_used(self):
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "SetNotify", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}])
|
||||
await self.send_msgs([{"cmd": "Get", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}])
|
||||
|
||||
def send_pending_freeincarnates(self):
|
||||
if self.freeincarnate_pending > 0:
|
||||
async_start(self.send_pending_freeincarnates_impl(self.freeincarnate_pending))
|
||||
self.freeincarnate_pending = 0
|
||||
|
||||
async def send_pending_freeincarnates_impl(self, send_val: int) -> None:
|
||||
await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used",
|
||||
"default": 0, "want_reply": False,
|
||||
"operations": [{"operation": "add", "value": send_val}]}])
|
||||
|
||||
async def used_freeincarnate(self) -> None:
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used",
|
||||
"default": 0, "want_reply": True,
|
||||
"operations": [{"operation": "add", "value": 1}]}])
|
||||
else:
|
||||
self.freeincarnate_pending = self.freeincarnate_pending + 1
|
||||
|
||||
|
||||
def convert_item_id(ap_item_id: int):
|
||||
static_item_index = ap_item_id - base_adventure_item_id
|
||||
return static_item_index * static_item_element_size
|
||||
|
||||
|
||||
def get_payload(ctx: AdventureContext):
|
||||
current_time = time.time()
|
||||
items = []
|
||||
dragon_speed_update = {}
|
||||
diff_a_locked = ctx.diff_a_mode > 0
|
||||
diff_b_locked = ctx.diff_b_mode > 0
|
||||
freeincarnate_count = 0
|
||||
for item in ctx.items_received:
|
||||
item_id_str = str(item.item)
|
||||
if base_adventure_item_id < item.item <= standard_item_max:
|
||||
items.append(convert_item_id(item.item))
|
||||
elif item_id_str in ctx.dragon_speed_info:
|
||||
if item.item in dragon_speed_update:
|
||||
last_index = len(ctx.dragon_speed_info[item_id_str]) - 1
|
||||
dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][last_index]
|
||||
else:
|
||||
dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][0]
|
||||
elif item.item == item_table["Left Difficulty Switch"].id:
|
||||
diff_a_locked = False
|
||||
elif item.item == item_table["Right Difficulty Switch"].id:
|
||||
diff_b_locked = False
|
||||
elif item.item == item_table["Freeincarnate"].id:
|
||||
freeincarnate_count = freeincarnate_count + 1
|
||||
freeincarnates_available = 0
|
||||
|
||||
if ctx.freeincarnates_used >= 0:
|
||||
freeincarnates_available = freeincarnate_count - (ctx.freeincarnates_used + ctx.freeincarnate_pending)
|
||||
ret = json.dumps(
|
||||
{
|
||||
"items": items,
|
||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
||||
if key[0] > current_time - 10},
|
||||
"deathlink": ctx.deathlink_pending,
|
||||
"dragon_speeds": dragon_speed_update,
|
||||
"difficulty_a_locked": diff_a_locked,
|
||||
"difficulty_b_locked": diff_b_locked,
|
||||
"freeincarnates_available": freeincarnates_available,
|
||||
"bat_logic": ctx.bat_logic
|
||||
}
|
||||
)
|
||||
ctx.deathlink_pending = False
|
||||
return ret
|
||||
|
||||
|
||||
async def parse_locations(data: List, ctx: AdventureContext):
|
||||
locations = data
|
||||
|
||||
# for loc_name, loc_data in location_table.items():
|
||||
|
||||
# if flags["EventFlag"][280] & 1 and not ctx.finished_game:
|
||||
# await ctx.send_msgs([
|
||||
# {"cmd": "StatusUpdate",
|
||||
# "status": 30}
|
||||
# ])
|
||||
# ctx.finished_game = True
|
||||
if locations == ctx.locations_array:
|
||||
return
|
||||
ctx.locations_array = locations
|
||||
if locations is not None:
|
||||
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
|
||||
|
||||
|
||||
def send_ap_foreign_items(adventure_context):
|
||||
foreign_item_json_list = []
|
||||
autocollect_item_json_list = []
|
||||
bat_no_touch_locations_json_list = []
|
||||
for fi in adventure_context.foreign_items:
|
||||
foreign_item_json_list.append(fi.get_dict())
|
||||
for fi in adventure_context.autocollect_items:
|
||||
autocollect_item_json_list.append(fi.get_dict())
|
||||
for ntl in adventure_context.bat_no_touch_locations:
|
||||
bat_no_touch_locations_json_list.append(ntl.get_dict())
|
||||
payload = json.dumps(
|
||||
{
|
||||
"foreign_items": foreign_item_json_list,
|
||||
"autocollect_items": autocollect_item_json_list,
|
||||
"local_item_locations": adventure_context.local_item_locations,
|
||||
"bat_no_touch_locations": bat_no_touch_locations_json_list
|
||||
}
|
||||
)
|
||||
print("sending foreign items")
|
||||
msg = payload.encode()
|
||||
(reader, writer) = adventure_context.atari_streams
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
|
||||
|
||||
def send_checked_locations_if_needed(adventure_context):
|
||||
if not adventure_context.checked_locations_sent and adventure_context.checked_locations is not None:
|
||||
if len(adventure_context.checked_locations) == 0:
|
||||
return
|
||||
checked_short_ids = []
|
||||
for location in adventure_context.checked_locations:
|
||||
checked_short_ids.append(location - base_location_id)
|
||||
print("Sending checked locations")
|
||||
payload = json.dumps(
|
||||
{
|
||||
"checked_locations": checked_short_ids,
|
||||
}
|
||||
)
|
||||
msg = payload.encode()
|
||||
(reader, writer) = adventure_context.atari_streams
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
adventure_context.checked_locations_sent = True
|
||||
|
||||
|
||||
async def atari_sync_task(ctx: AdventureContext):
|
||||
logger.info("Starting Atari 2600 connector. Use /2600 for status information")
|
||||
while not ctx.exit_event.is_set():
|
||||
try:
|
||||
error_status = None
|
||||
if ctx.atari_streams:
|
||||
(reader, writer) = ctx.atari_streams
|
||||
msg = get_payload(ctx).encode()
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with 1+ fields
|
||||
# 1. A keepalive response of the Players Name (always)
|
||||
# 2. romhash field with sha256 hash of the ROM memory region
|
||||
# 3. locations, messages, and deathLink
|
||||
# 4. freeincarnate, to indicate a freeincarnate was used
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
||||
data_decoded = json.loads(data.decode())
|
||||
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
|
||||
msg = "You are connecting with an incompatible Lua script version. Ensure your connector " \
|
||||
"Lua and AdventureClient are from the same Archipelago installation."
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
if ctx.seed_name and bytes(ctx.seed_name, encoding='ASCII') != ctx.seed_name_from_data:
|
||||
msg = "The server is running a different multiworld than your client is. " \
|
||||
"(invalid seed_name)"
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
if 'romhash' in data_decoded:
|
||||
if ctx.rom_hash.upper() != data_decoded['romhash'].upper():
|
||||
msg = "The rom hash does not match the client rom hash data"
|
||||
print("got " + data_decoded['romhash'])
|
||||
print("expected " + str(ctx.rom_hash))
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
if ctx.auth is None:
|
||||
ctx.auth = ctx.player_name
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
if 'locations' in data_decoded and ctx.game and ctx.atari_status == CONNECTION_CONNECTED_STATUS \
|
||||
and not error_status and ctx.auth:
|
||||
# Not just a keep alive ping, parse
|
||||
async_start(parse_locations(data_decoded['locations'], ctx))
|
||||
if 'deathLink' in data_decoded and data_decoded['deathLink'] > 0 and 'DeathLink' in ctx.tags:
|
||||
dragon_name = "a dragon"
|
||||
if data_decoded['deathLink'] == 1:
|
||||
dragon_name = "Rhindle"
|
||||
elif data_decoded['deathLink'] == 2:
|
||||
dragon_name = "Yorgle"
|
||||
elif data_decoded['deathLink'] == 3:
|
||||
dragon_name = "Grundle"
|
||||
print (ctx.auth + " has been eaten by " + dragon_name )
|
||||
await ctx.send_death(ctx.auth + " has been eaten by " + dragon_name)
|
||||
# TODO - also if player reincarnates with a dragon onscreen ' dies to avoid being eaten by '
|
||||
if 'victory' in data_decoded and not ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
if 'freeincarnate' in data_decoded:
|
||||
await ctx.used_freeincarnate()
|
||||
if ctx.set_deathlink:
|
||||
await ctx.update_death_link(True)
|
||||
send_checked_locations_if_needed(ctx)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
except CancelledError:
|
||||
logger.debug("Connection Cancelled, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.atari_streams = None
|
||||
pass
|
||||
except Exception as e:
|
||||
print("unknown exception " + e)
|
||||
raise
|
||||
if ctx.atari_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to 2600")
|
||||
ctx.atari_status = CONNECTION_CONNECTED_STATUS
|
||||
ctx.checked_locations_sent = False
|
||||
send_ap_foreign_items(ctx)
|
||||
send_checked_locations_if_needed(ctx)
|
||||
else:
|
||||
ctx.atari_status = f"Was tentatively connected but error occurred: {error_status}"
|
||||
elif error_status:
|
||||
ctx.atari_status = error_status
|
||||
logger.info("Lost connection to 2600 and attempting to reconnect. Use /2600 for status updates")
|
||||
else:
|
||||
try:
|
||||
port = ctx.lua_connector_port + ctx.port_offset
|
||||
logger.debug(f"Attempting to connect to 2600 on port {port}")
|
||||
print(f"Attempting to connect to 2600 on port {port}")
|
||||
ctx.atari_streams = await asyncio.wait_for(
|
||||
asyncio.open_connection("localhost",
|
||||
port),
|
||||
timeout=10)
|
||||
ctx.atari_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.atari_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
except CancelledError:
|
||||
pass
|
||||
except CancelledError:
|
||||
pass
|
||||
print("exiting atari sync task")
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
|
||||
rom_args = Utils.get_options()["adventure_options"].get("rom_args")
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif os.path.isfile(auto_start):
|
||||
open_args = [auto_start, romfile]
|
||||
if rom_args is not None:
|
||||
open_args.insert(1, rom_args)
|
||||
subprocess.Popen(open_args,
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def patch_and_run_game(patch_file, ctx):
|
||||
base_name = os.path.splitext(patch_file)[0]
|
||||
comp_path = base_name + '.a26'
|
||||
try:
|
||||
base_rom = AdventureDeltaPatch.get_source_data()
|
||||
except Exception as msg:
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
|
||||
with open(Utils.local_path("data", "adventure_basepatch.bsdiff4"), "rb") as file:
|
||||
basepatch = bytes(file.read())
|
||||
|
||||
base_patched_rom_data = bsdiff4.patch(base_rom, basepatch)
|
||||
|
||||
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
|
||||
if not AdventureDeltaPatch.check_version(patch_archive):
|
||||
logger.error("apadvn version doesn't match this client. Make sure your generator and client are the same")
|
||||
raise Exception("apadvn version doesn't match this client.")
|
||||
|
||||
ctx.foreign_items = AdventureDeltaPatch.read_foreign_items(patch_archive)
|
||||
ctx.autocollect_items = AdventureDeltaPatch.read_autocollect_items(patch_archive)
|
||||
ctx.local_item_locations = AdventureDeltaPatch.read_local_item_locations(patch_archive)
|
||||
ctx.dragon_speed_info = AdventureDeltaPatch.read_dragon_speed_info(patch_archive)
|
||||
ctx.seed_name_from_data, ctx.player_name = AdventureDeltaPatch.read_rom_info(patch_archive)
|
||||
ctx.diff_a_mode, ctx.diff_b_mode = AdventureDeltaPatch.read_difficulty_switch_info(patch_archive)
|
||||
ctx.bat_logic = AdventureDeltaPatch.read_bat_logic(patch_archive)
|
||||
ctx.bat_no_touch_locations = AdventureDeltaPatch.read_bat_no_touch(patch_archive)
|
||||
ctx.rom_deltas = AdventureDeltaPatch.read_rom_deltas(patch_archive)
|
||||
ctx.auth = ctx.player_name
|
||||
|
||||
patched_rom_data = AdventureDeltaPatch.apply_rom_deltas(base_patched_rom_data, ctx.rom_deltas)
|
||||
rom_hash = hashlib.sha256()
|
||||
rom_hash.update(patched_rom_data)
|
||||
ctx.rom_hash = rom_hash.hexdigest()
|
||||
ctx.port_offset = patched_rom_data[connector_port_offset]
|
||||
|
||||
with open(comp_path, "wb") as patched_rom_file:
|
||||
patched_rom_file.write(patched_rom_data)
|
||||
|
||||
async_start(run_game(comp_path))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Utils.init_logging("AdventureClient")
|
||||
|
||||
async def main():
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('patch_file', default="", type=str, nargs="?",
|
||||
help='Path to an ADVNTURE.BIN rom file')
|
||||
parser.add_argument('port', default=17242, type=int, nargs="?",
|
||||
help='port for adventure_connector connection')
|
||||
args = parser.parse_args()
|
||||
|
||||
ctx = AdventureContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
ctx.atari_sync_task = asyncio.create_task(atari_sync_task(ctx), name="Adventure Sync")
|
||||
|
||||
if args.patch_file:
|
||||
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
|
||||
if ext == "apadvn":
|
||||
logger.info("apadvn file supplied, beginning patching process...")
|
||||
async_start(patch_and_run_game(args.patch_file, ctx))
|
||||
else:
|
||||
logger.warning(f"Unknown patch file extension {ext}")
|
||||
if args.port is int:
|
||||
ctx.lua_connector_port = args.port
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.atari_sync_task:
|
||||
await ctx.atari_sync_task
|
||||
print("finished atari_sync_task (main)")
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
294
BaseClasses.py
@@ -7,9 +7,10 @@ import random
|
||||
import secrets
|
||||
import typing # this can go away when Python 3.8 support is dropped
|
||||
from argparse import Namespace
|
||||
from collections import OrderedDict, Counter, deque, ChainMap
|
||||
from collections import ChainMap, Counter, deque
|
||||
from enum import IntEnum, IntFlag
|
||||
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
|
||||
from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \
|
||||
Type, ClassVar
|
||||
|
||||
import NetUtils
|
||||
import Options
|
||||
@@ -28,15 +29,15 @@ class Group(TypedDict, total=False):
|
||||
link_replacement: bool
|
||||
|
||||
|
||||
class ThreadBarrierProxy():
|
||||
class ThreadBarrierProxy:
|
||||
"""Passes through getattr while passthrough is True"""
|
||||
def __init__(self, obj: Any):
|
||||
def __init__(self, obj: object) -> None:
|
||||
self.passthrough = True
|
||||
self.obj = obj
|
||||
|
||||
def __getattr__(self, item):
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
if self.passthrough:
|
||||
return getattr(self.obj, item)
|
||||
return getattr(self.obj, name)
|
||||
else:
|
||||
raise RuntimeError("You are in a threaded context and global random state was removed for your safety. "
|
||||
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
|
||||
@@ -67,7 +68,6 @@ class MultiWorld():
|
||||
local_early_items: Dict[int, Dict[str, int]]
|
||||
local_items: Dict[int, Options.LocalItems]
|
||||
non_local_items: Dict[int, Options.NonLocalItems]
|
||||
allow_collect: Dict[int, Options.AllowCollect]
|
||||
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
||||
completion_condition: Dict[int, Callable[[CollectionState], bool]]
|
||||
indirect_connections: Dict[Region, Set[Entrance]]
|
||||
@@ -82,6 +82,7 @@ class MultiWorld():
|
||||
|
||||
random: random.Random
|
||||
per_slot_randoms: Dict[int, random.Random]
|
||||
"""Deprecated. Please use `self.random` instead."""
|
||||
|
||||
class AttributeProxy():
|
||||
def __init__(self, rule):
|
||||
@@ -97,7 +98,6 @@ class MultiWorld():
|
||||
self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids}
|
||||
self.glitch_triforce = False
|
||||
self.algorithm = 'balanced'
|
||||
self.dungeons: Dict[Tuple[str, int], Dungeon] = {}
|
||||
self.groups = {}
|
||||
self.regions = []
|
||||
self.shops = []
|
||||
@@ -114,7 +114,6 @@ class MultiWorld():
|
||||
self.dark_world_light_cone = False
|
||||
self.rupoor_cost = 10
|
||||
self.aga_randomness = True
|
||||
self.lock_aga_door_in_escape = False
|
||||
self.save_and_quit_from_boss = True
|
||||
self.custom = False
|
||||
self.customitemarray = []
|
||||
@@ -123,6 +122,7 @@ class MultiWorld():
|
||||
self.early_items = {player: {} for player in self.player_ids}
|
||||
self.local_early_items = {player: {} for player in self.player_ids}
|
||||
self.indirect_connections = {}
|
||||
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
|
||||
self.fix_trock_doors = self.AttributeProxy(
|
||||
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
|
||||
self.fix_skullwoods_exit = self.AttributeProxy(
|
||||
@@ -136,7 +136,6 @@ class MultiWorld():
|
||||
def set_player_attr(attr, val):
|
||||
self.__dict__.setdefault(attr, {})[player] = val
|
||||
|
||||
set_player_attr('tech_tree_layout_prerequisites', {})
|
||||
set_player_attr('_region_cache', {})
|
||||
set_player_attr('shuffle', "vanilla")
|
||||
set_player_attr('logic', "noglitches")
|
||||
@@ -245,6 +244,7 @@ class MultiWorld():
|
||||
setattr(self, option_key, getattr(args, option_key, {}))
|
||||
|
||||
self.worlds[player] = world_type(self, player)
|
||||
self.worlds[player].random = self.per_slot_randoms[player]
|
||||
|
||||
def set_item_links(self):
|
||||
item_links = {}
|
||||
@@ -337,7 +337,7 @@ class MultiWorld():
|
||||
return self.player_name[player]
|
||||
|
||||
def get_file_safe_player_name(self, player: int) -> str:
|
||||
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
|
||||
return Utils.get_file_safe_name(self.get_player_name(player))
|
||||
|
||||
def get_out_file_name_base(self, player: int) -> str:
|
||||
""" the base name (without file extension) for each player's output file for a seed """
|
||||
@@ -388,12 +388,6 @@ class MultiWorld():
|
||||
self._recache()
|
||||
return self._location_cache[location, player]
|
||||
|
||||
def get_dungeon(self, dungeonname: str, player: int) -> Dungeon:
|
||||
try:
|
||||
return self.dungeons[dungeonname, player]
|
||||
except KeyError as e:
|
||||
raise KeyError('No such dungeon %s for player %d' % (dungeonname, player)) from e
|
||||
|
||||
def get_all_state(self, use_cache: bool) -> CollectionState:
|
||||
cached = getattr(self, "_all_state", None)
|
||||
if use_cache and cached:
|
||||
@@ -446,7 +440,6 @@ class MultiWorld():
|
||||
self.state.collect(item, True)
|
||||
|
||||
def push_item(self, location: Location, item: Item, collect: bool = True):
|
||||
assert location.can_fill(self.state, item, False), f"Cannot place {item} into {location}."
|
||||
location.item = item
|
||||
item.location = location
|
||||
if collect:
|
||||
@@ -494,8 +487,10 @@ class MultiWorld():
|
||||
def get_unfilled_locations_for_players(self, location_names: List[str], players: Iterable[int]):
|
||||
for player in players:
|
||||
if not location_names:
|
||||
location_names = [location.name for location in self.get_unfilled_locations(player)]
|
||||
for location_name in location_names:
|
||||
valid_locations = [location.name for location in self.get_unfilled_locations(player)]
|
||||
else:
|
||||
valid_locations = location_names
|
||||
for location_name in valid_locations:
|
||||
location = self._location_cache.get((location_name, player), None)
|
||||
if location is not None and location.item is None:
|
||||
yield location
|
||||
@@ -743,9 +738,11 @@ class CollectionState():
|
||||
return self.prog_items[item, player] >= count
|
||||
|
||||
def has_all(self, items: Set[str], player: int) -> bool:
|
||||
"""Returns True if each item name of items is in state at least once."""
|
||||
return all(self.prog_items[item, player] for item in items)
|
||||
|
||||
def has_any(self, items: Set[str], player: int) -> bool:
|
||||
"""Returns True if at least one item name of items is in state at least once."""
|
||||
return any(self.prog_items[item, player] for item in items)
|
||||
|
||||
def count(self, item: str, player: int) -> int:
|
||||
@@ -794,56 +791,6 @@ class CollectionState():
|
||||
self.stale[item.player] = True
|
||||
|
||||
|
||||
class Region:
|
||||
name: str
|
||||
_hint_text: str
|
||||
player: int
|
||||
multiworld: Optional[MultiWorld]
|
||||
entrances: List[Entrance]
|
||||
exits: List[Entrance]
|
||||
locations: List[Location]
|
||||
dungeon: Optional[Dungeon] = None
|
||||
|
||||
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
|
||||
self.name = name
|
||||
self.entrances = []
|
||||
self.exits = []
|
||||
self.locations = []
|
||||
self.multiworld = multiworld
|
||||
self._hint_text = hint
|
||||
self.player = player
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
if state.stale[self.player]:
|
||||
state.update_reachable_regions(self.player)
|
||||
return self in state.reachable_regions[self.player]
|
||||
|
||||
def can_reach_private(self, state: CollectionState) -> bool:
|
||||
for entrance in self.entrances:
|
||||
if entrance.can_reach(state):
|
||||
if not self in state.path:
|
||||
state.path[self] = (self.name, state.path.get(entrance, None))
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def hint_text(self) -> str:
|
||||
return self._hint_text if self._hint_text else self.name
|
||||
|
||||
def get_connecting_entrance(self, is_main_entrance: typing.Callable[[Entrance], bool]) -> Entrance:
|
||||
for entrance in self.entrances:
|
||||
if is_main_entrance(entrance):
|
||||
return entrance
|
||||
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
|
||||
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
|
||||
class Entrance:
|
||||
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
|
||||
hide_path: bool = False
|
||||
@@ -882,41 +829,92 @@ class Entrance:
|
||||
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
|
||||
|
||||
|
||||
class Dungeon(object):
|
||||
def __init__(self, name: str, regions: List[Region], big_key: Item, small_keys: List[Item],
|
||||
dungeon_items: List[Item], player: int):
|
||||
class Region:
|
||||
name: str
|
||||
_hint_text: str
|
||||
player: int
|
||||
multiworld: Optional[MultiWorld]
|
||||
entrances: List[Entrance]
|
||||
exits: List[Entrance]
|
||||
locations: List[Location]
|
||||
entrance_type: ClassVar[Type[Entrance]] = Entrance
|
||||
|
||||
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
|
||||
self.name = name
|
||||
self.regions = regions
|
||||
self.big_key = big_key
|
||||
self.small_keys = small_keys
|
||||
self.dungeon_items = dungeon_items
|
||||
self.bosses = dict()
|
||||
self.entrances = []
|
||||
self.exits = []
|
||||
self.locations = []
|
||||
self.multiworld = multiworld
|
||||
self._hint_text = hint
|
||||
self.player = player
|
||||
self.multiworld = None
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
if state.stale[self.player]:
|
||||
state.update_reachable_regions(self.player)
|
||||
return self in state.reachable_regions[self.player]
|
||||
|
||||
@property
|
||||
def boss(self) -> Optional[Boss]:
|
||||
return self.bosses.get(None, None)
|
||||
def hint_text(self) -> str:
|
||||
return self._hint_text if self._hint_text else self.name
|
||||
|
||||
@boss.setter
|
||||
def boss(self, value: Optional[Boss]):
|
||||
self.bosses[None] = value
|
||||
def get_connecting_entrance(self, is_main_entrance: Callable[[Entrance], bool]) -> Entrance:
|
||||
for entrance in self.entrances:
|
||||
if is_main_entrance(entrance):
|
||||
return entrance
|
||||
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
|
||||
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
|
||||
|
||||
@property
|
||||
def keys(self) -> List[Item]:
|
||||
return self.small_keys + ([self.big_key] if self.big_key else [])
|
||||
def add_locations(self, locations: Dict[str, Optional[int]],
|
||||
location_type: Optional[Type[Location]] = None) -> None:
|
||||
"""
|
||||
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
|
||||
location names to address.
|
||||
|
||||
:param locations: dictionary of locations to be created and added to this Region `{name: ID}`
|
||||
:param location_type: Location class to be used to create the locations with"""
|
||||
if location_type is None:
|
||||
location_type = Location
|
||||
for location, address in locations.items():
|
||||
self.locations.append(location_type(self.player, location, address, self))
|
||||
|
||||
def connect(self, connecting_region: Region, name: Optional[str] = None,
|
||||
rule: Optional[Callable[[CollectionState], bool]] = None) -> None:
|
||||
"""
|
||||
Connects this Region to another Region, placing the provided rule on the connection.
|
||||
|
||||
:param connecting_region: Region object to connect to path is `self -> exiting_region`
|
||||
:param name: name of the connection being created
|
||||
:param rule: callable to determine access of this connection to go from self to the exiting_region"""
|
||||
exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}")
|
||||
if rule:
|
||||
exit_.access_rule = rule
|
||||
exit_.connect(connecting_region)
|
||||
|
||||
def create_exit(self, name: str) -> Entrance:
|
||||
"""
|
||||
Creates and returns an Entrance object as an exit of this region.
|
||||
|
||||
:param name: name of the Entrance being created
|
||||
"""
|
||||
exit_ = self.entrance_type(self.player, name, self)
|
||||
self.exits.append(exit_)
|
||||
return exit_
|
||||
|
||||
@property
|
||||
def all_items(self) -> List[Item]:
|
||||
return self.dungeon_items + self.keys
|
||||
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
|
||||
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None:
|
||||
"""
|
||||
Connects current region to regions in exit dictionary. Passed region names must exist first.
|
||||
|
||||
def is_dungeon_item(self, item: Item) -> bool:
|
||||
return item.player == self.player and item.name in (dungeon_item.name for dungeon_item in self.all_items)
|
||||
|
||||
def __eq__(self, other: Dungeon) -> bool:
|
||||
if not other:
|
||||
return False
|
||||
return self.name == other.name and self.player == other.player
|
||||
: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"
|
||||
:param rules: rules for the exits from this region. format is {"connecting_region", rule}
|
||||
"""
|
||||
if not isinstance(exits, Dict):
|
||||
exits = dict.fromkeys(exits)
|
||||
for connecting_region, name in exits.items():
|
||||
self.connect(self.multiworld.get_region(connecting_region, self.player),
|
||||
name,
|
||||
rules[connecting_region] if rules and connecting_region in rules else None)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
@@ -925,20 +923,6 @@ class Dungeon(object):
|
||||
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
|
||||
|
||||
|
||||
class Boss():
|
||||
def __init__(self, name: str, enemizer_name: str, defeat_rule: Callable, player: int):
|
||||
self.name = name
|
||||
self.enemizer_name = enemizer_name
|
||||
self.defeat_rule = defeat_rule
|
||||
self.player = player
|
||||
|
||||
def can_defeat(self, state) -> bool:
|
||||
return self.defeat_rule(state, self.player)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Boss({self.name})"
|
||||
|
||||
|
||||
class LocationProgressType(IntEnum):
|
||||
DEFAULT = 1
|
||||
PRIORITY = 2
|
||||
@@ -1071,15 +1055,19 @@ class Item:
|
||||
def flags(self) -> int:
|
||||
return self.classification.as_flag()
|
||||
|
||||
def __eq__(self, other):
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Item):
|
||||
return NotImplemented
|
||||
return self.name == other.name and self.player == other.player
|
||||
|
||||
def __lt__(self, other: Item) -> bool:
|
||||
def __lt__(self, other: object) -> bool:
|
||||
if not isinstance(other, Item):
|
||||
return NotImplemented
|
||||
if other.player != self.player:
|
||||
return other.player < self.player
|
||||
return self.name < other.name
|
||||
|
||||
def __hash__(self):
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.name, self.player))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@@ -1091,33 +1079,44 @@ class Item:
|
||||
return f"{self.name} (Player {self.player})"
|
||||
|
||||
|
||||
class Spoiler():
|
||||
multiworld: MultiWorld
|
||||
unreachables: Set[Location]
|
||||
class EntranceInfo(TypedDict, total=False):
|
||||
player: int
|
||||
entrance: str
|
||||
exit: str
|
||||
direction: str
|
||||
|
||||
def __init__(self, world):
|
||||
self.multiworld = world
|
||||
|
||||
class Spoiler:
|
||||
multiworld: MultiWorld
|
||||
hashes: Dict[int, str]
|
||||
entrances: Dict[Tuple[str, str, int], EntranceInfo]
|
||||
playthrough: Dict[str, Union[List[str], Dict[str, str]]] # sphere "0" is list, others are dict
|
||||
unreachables: Set[Location]
|
||||
paths: Dict[str, List[Union[Tuple[str, str], Tuple[str, None]]]] # last step takes no further exits
|
||||
|
||||
def __init__(self, multiworld: MultiWorld) -> None:
|
||||
self.multiworld = multiworld
|
||||
self.hashes = {}
|
||||
self.entrances = OrderedDict()
|
||||
self.entrances = {}
|
||||
self.playthrough = {}
|
||||
self.unreachables = set()
|
||||
self.paths = {}
|
||||
|
||||
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
|
||||
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int) -> None:
|
||||
if self.multiworld.players == 1:
|
||||
self.entrances[(entrance, direction, player)] = OrderedDict(
|
||||
[('entrance', entrance), ('exit', exit_), ('direction', direction)])
|
||||
self.entrances[(entrance, direction, player)] = \
|
||||
{"entrance": entrance, "exit": exit_, "direction": direction}
|
||||
else:
|
||||
self.entrances[(entrance, direction, player)] = OrderedDict(
|
||||
[('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)])
|
||||
self.entrances[(entrance, direction, player)] = \
|
||||
{"player": player, "entrance": entrance, "exit": exit_, "direction": direction}
|
||||
|
||||
def create_playthrough(self, create_paths: bool = True):
|
||||
def create_playthrough(self, create_paths: bool = True) -> None:
|
||||
"""Destructive to the world while it is run, damage gets repaired afterwards."""
|
||||
from itertools import chain
|
||||
# get locations containing progress items
|
||||
multiworld = self.multiworld
|
||||
prog_locations = {location for location in multiworld.get_filled_locations() if location.item.advancement}
|
||||
state_cache = [None]
|
||||
state_cache: List[Optional[CollectionState]] = [None]
|
||||
collection_spheres: List[Set[Location]] = []
|
||||
state = CollectionState(multiworld)
|
||||
sphere_candidates = set(prog_locations)
|
||||
@@ -1209,7 +1208,7 @@ class Spoiler():
|
||||
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
|
||||
|
||||
# we can finally output our playthrough
|
||||
self.playthrough = {"0": sorted([str(item) for item in
|
||||
self.playthrough = {"0": sorted([self.multiworld.get_name_string_for_object(item) for item in
|
||||
chain.from_iterable(multiworld.precollected_items.values())
|
||||
if item.advancement])}
|
||||
|
||||
@@ -1226,17 +1225,17 @@ class Spoiler():
|
||||
for item in removed_precollected:
|
||||
multiworld.push_precollected(item)
|
||||
|
||||
def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]):
|
||||
def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]) -> None:
|
||||
from itertools import zip_longest
|
||||
multiworld = self.multiworld
|
||||
|
||||
def flist_to_iter(node):
|
||||
while node:
|
||||
value, node = node
|
||||
yield value
|
||||
def flist_to_iter(path_value: Optional[PathValue]) -> Iterator[str]:
|
||||
while path_value:
|
||||
region_or_entrance, path_value = path_value
|
||||
yield region_or_entrance
|
||||
|
||||
def get_path(state, region):
|
||||
reversed_path_as_flist = state.path.get(region, (region, None))
|
||||
def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, str], Tuple[str, None]]]:
|
||||
reversed_path_as_flist: PathValue = state.path.get(region, (str(region), None))
|
||||
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
|
||||
# Now we combine the flat string list into (region, exit) pairs
|
||||
pathsiter = iter(string_path_flat)
|
||||
@@ -1262,14 +1261,11 @@ class Spoiler():
|
||||
self.paths[str(multiworld.get_region('Inverted Big Bomb Shop', player))] = \
|
||||
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
|
||||
|
||||
def to_file(self, filename: str):
|
||||
def write_option(option_key: str, option_obj: type(Options.Option)):
|
||||
def to_file(self, filename: str) -> None:
|
||||
def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
|
||||
res = getattr(self.multiworld, option_key)[player]
|
||||
display_name = getattr(option_obj, "display_name", option_key)
|
||||
try:
|
||||
outfile.write(f'{display_name + ":":33}{res.current_option_name}\n')
|
||||
except:
|
||||
raise Exception
|
||||
outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
|
||||
|
||||
with open(filename, 'w', encoding="utf-8-sig") as outfile:
|
||||
outfile.write(
|
||||
@@ -1302,15 +1298,15 @@ class Spoiler():
|
||||
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
|
||||
|
||||
locations = [(str(location), str(location.item) if location.item is not None else "Nothing")
|
||||
for location in self.multiworld.get_locations() if location.show_in_spoiler]
|
||||
for location in self.multiworld.get_locations() if location.show_in_spoiler]
|
||||
outfile.write('\n\nLocations:\n\n')
|
||||
outfile.write('\n'.join(
|
||||
['%s: %s' % (location, item) for location, item in locations]))
|
||||
|
||||
outfile.write('\n\nPlaythrough:\n\n')
|
||||
outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join(
|
||||
[' %s: %s' % (location, item) for (location, item) in sphere.items()] if sphere_nr != '0' else [
|
||||
f' {item}' for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
|
||||
[f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else
|
||||
[f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
|
||||
if self.unreachables:
|
||||
outfile.write('\n\nUnreachable Items:\n\n')
|
||||
outfile.write(
|
||||
@@ -1371,23 +1367,21 @@ class PlandoOptions(IntFlag):
|
||||
@classmethod
|
||||
def _handle_part(cls, part: str, base: PlandoOptions) -> PlandoOptions:
|
||||
try:
|
||||
part = cls[part]
|
||||
return base | cls[part]
|
||||
except Exception as e:
|
||||
raise KeyError(f"{part} is not a recognized name for a plando module. "
|
||||
f"Known options: {', '.join(flag.name for flag in cls)}") from e
|
||||
else:
|
||||
return base | part
|
||||
f"Known options: {', '.join(str(flag.name) for flag in cls)}") from e
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.value:
|
||||
return ", ".join(flag.name for flag in PlandoOptions if self.value & flag.value)
|
||||
return ", ".join(str(flag.name) for flag in PlandoOptions if self.value & flag.value)
|
||||
return "None"
|
||||
|
||||
|
||||
seeddigits = 20
|
||||
|
||||
|
||||
def get_seed(seed=None) -> int:
|
||||
def get_seed(seed: Optional[int] = None) -> int:
|
||||
if seed is None:
|
||||
random.seed(None)
|
||||
return random.randint(0, pow(10, seeddigits) - 1)
|
||||
|
||||
134
CommonClient.py
@@ -23,6 +23,7 @@ from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
|
||||
from Utils import Version, stream_input, async_start
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
import os
|
||||
import ssl
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import kvui
|
||||
@@ -33,6 +34,12 @@ logger = logging.getLogger("Client")
|
||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
||||
|
||||
|
||||
@Utils.cache_argsless
|
||||
def get_ssl_context():
|
||||
import certifi
|
||||
return ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where())
|
||||
|
||||
|
||||
class ClientCommandProcessor(CommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
self.ctx = ctx
|
||||
@@ -68,14 +75,17 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
|
||||
return True
|
||||
|
||||
def _cmd_missing(self) -> bool:
|
||||
"""List all missing location checks, from your local game state"""
|
||||
def _cmd_missing(self, filter_text = "") -> bool:
|
||||
"""List all missing location checks, from your local game state.
|
||||
Can be given text, which will be used as filter."""
|
||||
if not self.ctx.game:
|
||||
self.output("No game set, cannot determine missing checks.")
|
||||
return False
|
||||
count = 0
|
||||
checked_count = 0
|
||||
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
||||
if filter_text and filter_text not in location:
|
||||
continue
|
||||
if location_id < 0:
|
||||
continue
|
||||
if location_id not in self.ctx.locations_checked:
|
||||
@@ -136,7 +146,7 @@ class CommonContext:
|
||||
items_handling: typing.Optional[int] = None
|
||||
want_slot_data: bool = True # should slot_data be retrieved via Connect
|
||||
|
||||
# datapackage
|
||||
# data package
|
||||
# Contents in flux until connection to server is made, to download correct data for this multiworld.
|
||||
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||
@@ -154,6 +164,7 @@ class CommonContext:
|
||||
disconnected_intentionally: bool = False
|
||||
server: typing.Optional[Endpoint] = None
|
||||
server_version: Version = Version(0, 0, 0)
|
||||
generator_version: Version = Version(0, 0, 0)
|
||||
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
|
||||
|
||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||
@@ -163,6 +174,7 @@ class CommonContext:
|
||||
server_address: typing.Optional[str]
|
||||
password: typing.Optional[str]
|
||||
hint_cost: typing.Optional[int]
|
||||
hint_points: typing.Optional[int]
|
||||
player_names: typing.Dict[int, str]
|
||||
|
||||
finished_game: bool
|
||||
@@ -179,6 +191,10 @@ class CommonContext:
|
||||
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
||||
locations_info: typing.Dict[int, NetworkItem]
|
||||
|
||||
# data storage
|
||||
stored_data: typing.Dict[str, typing.Any]
|
||||
stored_data_notification_keys: typing.Set[str]
|
||||
|
||||
# internals
|
||||
# current message box through kvui
|
||||
_messagebox: typing.Optional["kvui.MessageBox"] = None
|
||||
@@ -214,6 +230,9 @@ class CommonContext:
|
||||
self.server_locations = set() # all locations the server knows of, missing_location | checked_locations
|
||||
self.locations_info = {}
|
||||
|
||||
self.stored_data = {}
|
||||
self.stored_data_notification_keys = set()
|
||||
|
||||
self.input_queue = asyncio.Queue()
|
||||
self.input_requests = 0
|
||||
|
||||
@@ -223,7 +242,7 @@ class CommonContext:
|
||||
self.watcher_event = asyncio.Event()
|
||||
|
||||
self.jsontotextparser = JSONtoTextParser(self)
|
||||
self.update_datapackage(network_data_package)
|
||||
self.update_data_package(network_data_package)
|
||||
|
||||
# execution
|
||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||
@@ -256,6 +275,7 @@ class CommonContext:
|
||||
self.items_received = []
|
||||
self.locations_info = {}
|
||||
self.server_version = Version(0, 0, 0)
|
||||
self.generator_version = Version(0, 0, 0)
|
||||
self.server = None
|
||||
self.server_task = None
|
||||
self.hint_cost = None
|
||||
@@ -399,32 +419,40 @@ class CommonContext:
|
||||
self.input_task.cancel()
|
||||
|
||||
# DataPackage
|
||||
async def prepare_datapackage(self, relevant_games: typing.Set[str],
|
||||
remote_datepackage_versions: typing.Dict[str, int]):
|
||||
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]):
|
||||
"""Validate that all data is present for the current multiworld.
|
||||
Download, assimilate and cache missing data from the server."""
|
||||
# by documentation any game can use Archipelago locations/items -> always relevant
|
||||
relevant_games.add("Archipelago")
|
||||
|
||||
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||
needed_updates: typing.Set[str] = set()
|
||||
for game in relevant_games:
|
||||
if game not in remote_datepackage_versions:
|
||||
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
|
||||
continue
|
||||
remote_version: int = remote_datepackage_versions[game]
|
||||
|
||||
if remote_version == 0: # custom datapackage for this game
|
||||
remote_version: int = remote_date_package_versions.get(game, 0)
|
||||
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
|
||||
|
||||
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
|
||||
needed_updates.add(game)
|
||||
continue
|
||||
|
||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
||||
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
|
||||
# no action required if local version is new enough
|
||||
if remote_version > local_version:
|
||||
cache_version: int = cache_package.get(game, {}).get("version", 0)
|
||||
if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
|
||||
or remote_checksum != local_checksum:
|
||||
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
|
||||
cache_version: int = cached_game.get("version", 0)
|
||||
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
|
||||
# download remote version if cache is not new enough
|
||||
if remote_version > cache_version:
|
||||
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
|
||||
or remote_checksum != cache_checksum:
|
||||
needed_updates.add(game)
|
||||
else:
|
||||
self.update_game(cache_package[game])
|
||||
self.update_game(cached_game)
|
||||
if needed_updates:
|
||||
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
|
||||
|
||||
@@ -434,15 +462,32 @@ class CommonContext:
|
||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||
self.location_names[location_id] = location_name
|
||||
|
||||
def update_datapackage(self, data_package: dict):
|
||||
for game, gamedata in data_package["games"].items():
|
||||
self.update_game(gamedata)
|
||||
def update_data_package(self, data_package: dict):
|
||||
for game, game_data in data_package["games"].items():
|
||||
self.update_game(game_data)
|
||||
|
||||
def consume_network_datapackage(self, data_package: dict):
|
||||
self.update_datapackage(data_package)
|
||||
def consume_network_data_package(self, data_package: dict):
|
||||
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)
|
||||
for game, game_data in data_package["games"].items():
|
||||
Utils.store_data_package_for_checksum(game, game_data)
|
||||
|
||||
# data storage
|
||||
|
||||
def set_notify(self, *keys: str) -> None:
|
||||
"""Subscribe to be notified of changes to selected data storage keys.
|
||||
|
||||
The values can be accessed via the "stored_data" attribute of this context, which is a dictionary mapping the
|
||||
names of the data storage keys to the latest values received from the server.
|
||||
"""
|
||||
if new_keys := (set(keys) - self.stored_data_notification_keys):
|
||||
self.stored_data_notification_keys.update(new_keys)
|
||||
async_start(self.send_msgs([{"cmd": "Get",
|
||||
"keys": list(new_keys)},
|
||||
{"cmd": "SetNotify",
|
||||
"keys": list(new_keys)}]))
|
||||
|
||||
# DeathLink hooks
|
||||
|
||||
@@ -573,7 +618,8 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
||||
|
||||
logger.info(f'Connecting to Archipelago server at {address}')
|
||||
try:
|
||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
|
||||
ssl=get_ssl_context() if address.startswith("wss://") else None)
|
||||
if ctx.ui is not None:
|
||||
ctx.ui.update_address_bar(server_url.netloc)
|
||||
ctx.server = Endpoint(socket)
|
||||
@@ -588,6 +634,7 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
||||
except websockets.InvalidMessage:
|
||||
# probably encrypted
|
||||
if address.startswith("ws://"):
|
||||
# try wss
|
||||
await server_loop(ctx, "ws" + address[1:])
|
||||
else:
|
||||
ctx.handle_connection_loss(f"Lost connection to the multiworld server due to InvalidMessage"
|
||||
@@ -632,11 +679,16 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
logger.info('Room Information:')
|
||||
logger.info('--------------------------------')
|
||||
version = args["version"]
|
||||
ctx.server_version = tuple(version)
|
||||
version = ".".join(str(item) for item in version)
|
||||
ctx.server_version = Version(*version)
|
||||
|
||||
logger.info(f'Server protocol version: {version}')
|
||||
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
|
||||
if "generator_version" in args:
|
||||
ctx.generator_version = Version(*args["generator_version"])
|
||||
logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, '
|
||||
f'generator version: {ctx.generator_version.as_simple_string()}, '
|
||||
f'tags: {", ".join(args["tags"])}')
|
||||
else:
|
||||
logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, '
|
||||
f'tags: {", ".join(args["tags"])}')
|
||||
if args['password']:
|
||||
logger.info('Password required')
|
||||
ctx.update_permissions(args.get("permissions", {}))
|
||||
@@ -661,14 +713,16 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
current_team = network_player.team
|
||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||
|
||||
# update datapackage
|
||||
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
|
||||
# update data package
|
||||
data_package_versions = args.get("datapackage_versions", {})
|
||||
data_package_checksums = args.get("datapackage_checksums", {})
|
||||
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
|
||||
|
||||
await ctx.server_auth(args['password'])
|
||||
|
||||
elif cmd == 'DataPackage':
|
||||
logger.info("Got new ID/Name DataPackage")
|
||||
ctx.consume_network_datapackage(args['data'])
|
||||
ctx.consume_network_data_package(args['data'])
|
||||
|
||||
elif cmd == 'ConnectionRefused':
|
||||
errors = args["errors"]
|
||||
@@ -696,6 +750,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.slot = args["slot"]
|
||||
# int keys get lost in JSON transfer
|
||||
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
|
||||
ctx.hint_points = args.get("hint_points", 0)
|
||||
ctx.consume_players_package(args["players"])
|
||||
msgs = []
|
||||
if ctx.locations_checked:
|
||||
@@ -704,6 +759,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
if ctx.locations_scouted:
|
||||
msgs.append({"cmd": "LocationScouts",
|
||||
"locations": list(ctx.locations_scouted)})
|
||||
if ctx.stored_data_notification_keys:
|
||||
msgs.append({"cmd": "Get",
|
||||
"keys": list(ctx.stored_data_notification_keys)})
|
||||
msgs.append({"cmd": "SetNotify",
|
||||
"keys": list(ctx.stored_data_notification_keys)})
|
||||
if msgs:
|
||||
await ctx.send_msgs(msgs)
|
||||
if ctx.finished_game:
|
||||
@@ -767,8 +827,13 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
|
||||
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
|
||||
ctx.on_deathlink(args["data"])
|
||||
|
||||
elif cmd == "Retrieved":
|
||||
ctx.stored_data.update(args["keys"])
|
||||
|
||||
elif cmd == "SetReply":
|
||||
if args["key"] == "EnergyLink":
|
||||
ctx.stored_data[args["key"]] = args["value"]
|
||||
if args["key"].startswith("EnergyLink"):
|
||||
ctx.current_energy_link_value = args["value"]
|
||||
if ctx.ui:
|
||||
ctx.ui.set_new_energy_link_value()
|
||||
@@ -808,10 +873,9 @@ def get_base_parser(description: typing.Optional[str] = None):
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
|
||||
def run_as_textclient():
|
||||
class TextContext(CommonContext):
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
tags = {"AP", "TextOnly"}
|
||||
game = "" # empty matches any game since 0.3.2
|
||||
items_handling = 0b111 # receive all items for /received
|
||||
@@ -826,12 +890,11 @@ if __name__ == '__main__':
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.slot_info[self.slot].game
|
||||
|
||||
|
||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||
self.game = ""
|
||||
await super().disconnect(allow_autoreconnect)
|
||||
|
||||
|
||||
async def main(args):
|
||||
ctx = TextContext(args.connect, args.password)
|
||||
ctx.auth = args.name
|
||||
@@ -844,7 +907,6 @@ if __name__ == '__main__':
|
||||
await ctx.exit_event.wait()
|
||||
await ctx.shutdown()
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
||||
@@ -864,3 +926,7 @@ if __name__ == '__main__':
|
||||
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run_as_textclient()
|
||||
|
||||
@@ -13,9 +13,9 @@ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandP
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart ff1_connector.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure ff1_connector.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart ff1_connector.lua"
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure 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"
|
||||
@@ -33,7 +33,7 @@ class FF1CommandProcessor(ClientCommandProcessor):
|
||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
||||
|
||||
def _cmd_toggle_msgs(self):
|
||||
"""Toggle displaying messages in bizhawk"""
|
||||
"""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'}")
|
||||
|
||||
@@ -1,553 +1,12 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import string
|
||||
import copy
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
import typing
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import factorio_rcon
|
||||
import colorama
|
||||
import asyncio
|
||||
from queue import Queue
|
||||
from worlds.factorio.Client import check_stdin, launch
|
||||
import Utils
|
||||
|
||||
def check_stdin() -> None:
|
||||
if Utils.is_windows and sys.stdin:
|
||||
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||
check_stdin()
|
||||
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
|
||||
from MultiServer import mark_raw
|
||||
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||
from Utils import async_start
|
||||
|
||||
from worlds.factorio import Factorio
|
||||
|
||||
|
||||
class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
ctx: FactorioContext
|
||||
|
||||
def _cmd_energy_link(self):
|
||||
"""Print the status of the energy link."""
|
||||
self.output(f"Energy Link: {self.ctx.energy_link_status}")
|
||||
|
||||
@mark_raw
|
||||
def _cmd_factorio(self, text: str) -> bool:
|
||||
"""Send the following command to the bound Factorio Server."""
|
||||
if self.ctx.rcon_client:
|
||||
# TODO: Print the command non-silently only for race seeds, or otherwise block anything but /factorio /save in race seeds.
|
||||
self.ctx.print_to_game(f"/factorio {text}")
|
||||
result = self.ctx.rcon_client.send_command(text)
|
||||
if result:
|
||||
self.output(result)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.ctx.awaiting_bridge = True
|
||||
|
||||
def _cmd_toggle_send_filter(self):
|
||||
"""Toggle filtering of item sends that get displayed in-game to only those that involve you."""
|
||||
self.ctx.toggle_filter_item_sends()
|
||||
|
||||
def _cmd_toggle_chat(self):
|
||||
"""Toggle sending of chat messages from players on the Factorio server to Archipelago."""
|
||||
self.ctx.toggle_bridge_chat_out()
|
||||
|
||||
class FactorioContext(CommonContext):
|
||||
command_processor = FactorioCommandProcessor
|
||||
game = "Factorio"
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
# updated by spinup server
|
||||
mod_version: Utils.Version = Utils.Version(0, 0, 0)
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(FactorioContext, self).__init__(server_address, password)
|
||||
self.send_index: int = 0
|
||||
self.rcon_client = None
|
||||
self.awaiting_bridge = False
|
||||
self.write_data_path = None
|
||||
self.death_link_tick: int = 0 # last send death link on Factorio layer
|
||||
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
||||
self.energy_link_increment = 0
|
||||
self.last_deplete = 0
|
||||
self.filter_item_sends: bool = False
|
||||
self.multiplayer: bool = False # whether multiple different players have connected
|
||||
self.bridge_chat_out: bool = True
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(FactorioContext, self).server_auth(password_requested)
|
||||
|
||||
if self.rcon_client:
|
||||
await get_info(self, self.rcon_client) # retrieve current auth code
|
||||
else:
|
||||
raise Exception("Cannot connect to a server with unknown own identity, "
|
||||
"bridge to Factorio first.")
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def on_print(self, args: dict):
|
||||
super(FactorioContext, self).on_print(args)
|
||||
if self.rcon_client:
|
||||
if not args['text'].startswith(self.player_names[self.slot] + ":"):
|
||||
self.print_to_game(args['text'])
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if self.rcon_client:
|
||||
if (not self.filter_item_sends or not self.is_uninteresting_item_send(args)) \
|
||||
and not self.is_echoed_chat(args):
|
||||
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
|
||||
if not text.startswith(self.player_names[self.slot] + ":"): # TODO: Remove string heuristic in the future.
|
||||
self.print_to_game(text)
|
||||
super(FactorioContext, self).on_print_json(args)
|
||||
|
||||
@property
|
||||
def savegame_name(self) -> str:
|
||||
return f"AP_{self.seed_name}_{self.auth}_Save.zip"
|
||||
|
||||
def print_to_game(self, text):
|
||||
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
||||
f"{text}")
|
||||
|
||||
@property
|
||||
def energy_link_status(self) -> str:
|
||||
if not self.energy_link_increment:
|
||||
return "Disabled"
|
||||
elif self.current_energy_link_value is None:
|
||||
return "Standby"
|
||||
else:
|
||||
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J"
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
if self.rcon_client:
|
||||
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
|
||||
super(FactorioContext, self).on_deathlink(data)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected", "RoomUpdate"}:
|
||||
# catch up sync anything that is already cleared.
|
||||
if "checked_locations" in args and args["checked_locations"]:
|
||||
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
||||
item_name in args["checked_locations"]})
|
||||
if cmd == "Connected" and self.energy_link_increment:
|
||||
async_start(self.send_msgs([{
|
||||
"cmd": "SetNotify", "keys": ["EnergyLink"]
|
||||
}]))
|
||||
elif cmd == "SetReply":
|
||||
if args["key"] == "EnergyLink":
|
||||
if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete:
|
||||
# it's our deplete request
|
||||
gained = int(args["original_value"] - args["value"])
|
||||
gained_text = Utils.format_SI_prefix(gained) + "J"
|
||||
if gained:
|
||||
logger.debug(f"EnergyLink: Received {gained_text}. "
|
||||
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
||||
self.rcon_client.send_command(f"/ap-energylink {gained}")
|
||||
|
||||
def on_user_say(self, text: str) -> typing.Optional[str]:
|
||||
# Mirror chat sent from the UI to the Factorio server.
|
||||
self.print_to_game(f"{self.player_names[self.slot]}: {text}")
|
||||
return text
|
||||
|
||||
async def chat_from_factorio(self, user: str, message: str) -> None:
|
||||
if not self.bridge_chat_out:
|
||||
return
|
||||
|
||||
# Pass through commands
|
||||
if message.startswith("!"):
|
||||
await self.send_msgs([{"cmd": "Say", "text": message}])
|
||||
return
|
||||
|
||||
# Omit messages that contain local coordinates
|
||||
if "[gps=" in message:
|
||||
return
|
||||
|
||||
prefix = f"({user}) " if self.multiplayer else ""
|
||||
await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}])
|
||||
|
||||
def toggle_filter_item_sends(self) -> None:
|
||||
self.filter_item_sends = not self.filter_item_sends
|
||||
if self.filter_item_sends:
|
||||
announcement = "Item sends are now filtered."
|
||||
else:
|
||||
announcement = "Item sends are no longer filtered."
|
||||
logger.info(announcement)
|
||||
self.print_to_game(announcement)
|
||||
|
||||
def toggle_bridge_chat_out(self) -> None:
|
||||
self.bridge_chat_out = not self.bridge_chat_out
|
||||
if self.bridge_chat_out:
|
||||
announcement = "Chat is now bridged to Archipelago."
|
||||
else:
|
||||
announcement = "Chat is no longer bridged to Archipelago."
|
||||
logger.info(announcement)
|
||||
self.print_to_game(announcement)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class FactorioManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("FactorioServer", "Factorio Server Log"),
|
||||
("FactorioWatcher", "Bridge Data Log"),
|
||||
]
|
||||
base_title = "Archipelago Factorio Client"
|
||||
|
||||
self.ui = FactorioManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
async def game_watcher(ctx: FactorioContext):
|
||||
bridge_logger = logging.getLogger("FactorioWatcher")
|
||||
next_bridge = time.perf_counter() + 1
|
||||
try:
|
||||
while not ctx.exit_event.is_set():
|
||||
# TODO: restore on-demand refresh
|
||||
if ctx.rcon_client and time.perf_counter() > next_bridge:
|
||||
next_bridge = time.perf_counter() + 1
|
||||
ctx.awaiting_bridge = False
|
||||
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
|
||||
if not ctx.auth:
|
||||
pass # auth failed, wait for new attempt
|
||||
elif data["slot_name"] != ctx.auth:
|
||||
bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
|
||||
elif data["seed_name"] != ctx.seed_name:
|
||||
bridge_logger.warning(
|
||||
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
|
||||
else:
|
||||
data = data["info"]
|
||||
research_data = data["research_done"]
|
||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||
victory = data["victory"]
|
||||
await ctx.update_death_link(data["death_link"])
|
||||
ctx.multiplayer = data.get("multiplayer", False)
|
||||
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
|
||||
if ctx.locations_checked != research_data:
|
||||
bridge_logger.debug(
|
||||
f"New researches done: "
|
||||
f"{[ctx.location_names[rid] for rid in research_data - ctx.locations_checked]}")
|
||||
ctx.locations_checked = research_data
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
||||
death_link_tick = data.get("death_link_tick", 0)
|
||||
if death_link_tick != ctx.death_link_tick:
|
||||
ctx.death_link_tick = death_link_tick
|
||||
if "DeathLink" in ctx.tags:
|
||||
async_start(ctx.send_death())
|
||||
if ctx.energy_link_increment:
|
||||
in_world_bridges = data["energy_bridges"]
|
||||
if in_world_bridges:
|
||||
in_world_energy = data["energy"]
|
||||
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
|
||||
# attempt to refill
|
||||
ctx.last_deplete = time.time()
|
||||
async_start(ctx.send_msgs([{
|
||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
|
||||
{"operation": "max", "value": 0}],
|
||||
"last_deplete": ctx.last_deplete
|
||||
}]))
|
||||
# Above Capacity - (len(Bridges) * ENERGY_INCREMENT)
|
||||
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
|
||||
ctx.energy_link_increment*in_world_bridges:
|
||||
value = ctx.energy_link_increment * in_world_bridges
|
||||
async_start(ctx.send_msgs([{
|
||||
"cmd": "Set", "key": "EnergyLink", "operations":
|
||||
[{"operation": "add", "value": value}]
|
||||
}]))
|
||||
ctx.rcon_client.send_command(
|
||||
f"/ap-energylink -{value}")
|
||||
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.error("Aborted Factorio Server Bridge")
|
||||
|
||||
|
||||
def stream_factorio_output(pipe, queue, process):
|
||||
pipe.reconfigure(errors="replace")
|
||||
|
||||
def queuer():
|
||||
while process.poll() is None:
|
||||
text = pipe.readline().strip()
|
||||
if text:
|
||||
queue.put_nowait(text)
|
||||
|
||||
from threading import Thread
|
||||
|
||||
thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
async def factorio_server_watcher(ctx: FactorioContext):
|
||||
savegame_name = os.path.abspath(ctx.savegame_name)
|
||||
if not os.path.exists(savegame_name):
|
||||
logger.info(f"Creating savegame {savegame_name}")
|
||||
subprocess.run((
|
||||
executable, "--create", savegame_name, "--preset", "archipelago"
|
||||
))
|
||||
factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name,
|
||||
*(str(elem) for elem in server_args)),
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
encoding="utf-8")
|
||||
factorio_server_logger.info("Started Factorio Server")
|
||||
factorio_queue = Queue()
|
||||
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
|
||||
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
||||
try:
|
||||
while not ctx.exit_event.is_set():
|
||||
if factorio_process.poll() is not None:
|
||||
factorio_server_logger.info("Factorio server has exited.")
|
||||
ctx.exit_event.set()
|
||||
|
||||
while not factorio_queue.empty():
|
||||
msg = factorio_queue.get()
|
||||
factorio_queue.task_done()
|
||||
|
||||
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
||||
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
||||
if not ctx.server:
|
||||
logger.info("Established bridge to Factorio Server. "
|
||||
"Ready to connect to Archipelago via /connect")
|
||||
check_stdin()
|
||||
|
||||
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
|
||||
ctx.awaiting_bridge = True
|
||||
factorio_server_logger.debug(msg)
|
||||
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command energy-link$", msg):
|
||||
factorio_server_logger.debug(msg)
|
||||
ctx.print_to_game(f"Energy Link: {ctx.energy_link_status}")
|
||||
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter$", msg):
|
||||
factorio_server_logger.debug(msg)
|
||||
ctx.toggle_filter_item_sends()
|
||||
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg):
|
||||
factorio_server_logger.debug(msg)
|
||||
ctx.toggle_bridge_chat_out()
|
||||
else:
|
||||
factorio_server_logger.info(msg)
|
||||
match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg)
|
||||
if match:
|
||||
await ctx.chat_from_factorio(match.group(1), match.group(2))
|
||||
if ctx.rcon_client:
|
||||
commands = {}
|
||||
while ctx.send_index < len(ctx.items_received):
|
||||
transfer_item: NetworkItem = ctx.items_received[ctx.send_index]
|
||||
item_id = transfer_item.item
|
||||
player_name = ctx.player_names[transfer_item.player]
|
||||
if item_id not in Factorio.item_id_to_name:
|
||||
factorio_server_logger.error(f"Cannot send unknown item ID: {item_id}")
|
||||
else:
|
||||
item_name = Factorio.item_id_to_name[item_id]
|
||||
factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.")
|
||||
commands[ctx.send_index] = f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}'
|
||||
ctx.send_index += 1
|
||||
if commands:
|
||||
ctx.rcon_client.send_commands(commands)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.error("Aborted Factorio Server Bridge")
|
||||
ctx.exit_event.set()
|
||||
|
||||
finally:
|
||||
if factorio_process.poll() is not None:
|
||||
if ctx.rcon_client:
|
||||
ctx.rcon_client.close()
|
||||
ctx.rcon_client = None
|
||||
return
|
||||
|
||||
sent_quit = False
|
||||
if ctx.rcon_client:
|
||||
# Attempt clean quit through RCON.
|
||||
try:
|
||||
ctx.rcon_client.send_command("/quit")
|
||||
except factorio_rcon.RCONNetworkError:
|
||||
pass
|
||||
else:
|
||||
sent_quit = True
|
||||
ctx.rcon_client.close()
|
||||
ctx.rcon_client = None
|
||||
if not sent_quit:
|
||||
# Attempt clean quit using SIGTERM. (Note that on Windows this kills the process instead.)
|
||||
factorio_process.terminate()
|
||||
|
||||
try:
|
||||
factorio_process.wait(10)
|
||||
except subprocess.TimeoutExpired:
|
||||
factorio_process.kill()
|
||||
|
||||
|
||||
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
|
||||
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
|
||||
ctx.auth = info["slot_name"]
|
||||
ctx.seed_name = info["seed_name"]
|
||||
# 0.2.0 addition, not present earlier
|
||||
death_link = bool(info.get("death_link", False))
|
||||
ctx.energy_link_increment = info.get("energy_link", 0)
|
||||
logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}")
|
||||
if ctx.energy_link_increment and ctx.ui:
|
||||
ctx.ui.enable_energy_link()
|
||||
await ctx.update_death_link(death_link)
|
||||
|
||||
|
||||
async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
savegame_name = os.path.abspath("Archipelago.zip")
|
||||
if not os.path.exists(savegame_name):
|
||||
logger.info(f"Creating savegame {savegame_name}")
|
||||
subprocess.run((
|
||||
executable, "--create", savegame_name
|
||||
))
|
||||
factorio_process = subprocess.Popen(
|
||||
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
encoding="utf-8")
|
||||
factorio_server_logger.info("Started Information Exchange Factorio Server")
|
||||
factorio_queue = Queue()
|
||||
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
|
||||
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
||||
rcon_client = None
|
||||
try:
|
||||
while not ctx.auth:
|
||||
while not factorio_queue.empty():
|
||||
msg = factorio_queue.get()
|
||||
factorio_server_logger.info(msg)
|
||||
if "Loading mod AP-" in msg and msg.endswith("(data.lua)"):
|
||||
parts = msg.split()
|
||||
ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split(".")))
|
||||
elif "Write data path: " in msg:
|
||||
ctx.write_data_path = Utils.get_text_between(msg, "Write data path: ", " [")
|
||||
if "AppData" in ctx.write_data_path:
|
||||
logger.warning("It appears your mods are loaded from Appdata, "
|
||||
"this can lead to problems with multiple Factorio instances. "
|
||||
"If this is the case, you will get a file locked error running Factorio.")
|
||||
if not rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
||||
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
||||
if ctx.mod_version == ctx.__class__.mod_version:
|
||||
raise Exception("No Archipelago mod was loaded. Aborting.")
|
||||
await get_info(ctx, rcon_client)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(e, extra={"compact_gui": True})
|
||||
msg = "Aborted Factorio Server Bridge"
|
||||
logger.error(msg)
|
||||
ctx.gui_error(msg, e)
|
||||
ctx.exit_event.set()
|
||||
|
||||
else:
|
||||
logger.info(
|
||||
f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}")
|
||||
return True
|
||||
finally:
|
||||
factorio_process.terminate()
|
||||
factorio_process.wait(5)
|
||||
return False
|
||||
|
||||
|
||||
async def main(args):
|
||||
ctx = FactorioContext(args.connect, args.password)
|
||||
ctx.filter_item_sends = initial_filter_item_sends
|
||||
ctx.bridge_chat_out = initial_bridge_chat_out
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
|
||||
successful_launch = await factorio_server_task
|
||||
if successful_launch:
|
||||
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
|
||||
progression_watcher = asyncio.create_task(
|
||||
game_watcher(ctx), name="FactorioProgressionWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await progression_watcher
|
||||
await factorio_server_task
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
|
||||
class FactorioJSONtoTextParser(JSONtoTextParser):
|
||||
def _handle_color(self, node: JSONMessagePart):
|
||||
colors = node["color"].split(";")
|
||||
for color in colors:
|
||||
if color in self.color_codes:
|
||||
node["text"] = f"[color=#{self.color_codes[color]}]{node['text']}[/color]"
|
||||
return self._handle_text(node)
|
||||
return self._handle_text(node)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_base_parser(description="Optional arguments to FactorioClient follow. "
|
||||
"Remaining arguments get passed into bound Factorio instance."
|
||||
"Refer to Factorio --help for those.")
|
||||
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
|
||||
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
|
||||
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
rcon_port = args.rcon_port
|
||||
rcon_password = args.rcon_password if args.rcon_password else ''.join(
|
||||
random.choice(string.ascii_letters) for x in range(32))
|
||||
|
||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||
options = Utils.get_options()
|
||||
executable = options["factorio_options"]["executable"]
|
||||
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
|
||||
if server_settings:
|
||||
server_settings = os.path.abspath(server_settings)
|
||||
if not isinstance(options["factorio_options"]["filter_item_sends"], bool):
|
||||
logging.warning(f"Warning: Option filter_item_sends should be a bool.")
|
||||
initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"])
|
||||
if not isinstance(options["factorio_options"]["bridge_chat_out"], bool):
|
||||
logging.warning(f"Warning: Option bridge_chat_out should be a bool.")
|
||||
initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"])
|
||||
|
||||
if not os.path.exists(os.path.dirname(executable)):
|
||||
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
||||
if os.path.isdir(executable): # user entered a path to a directory, let's find the executable therein
|
||||
executable = os.path.join(executable, "factorio")
|
||||
if not os.path.isfile(executable):
|
||||
if os.path.isfile(executable + ".exe"):
|
||||
executable = executable + ".exe"
|
||||
else:
|
||||
raise FileNotFoundError(f"Path {executable} is not an executable file.")
|
||||
|
||||
if server_settings and os.path.isfile(server_settings):
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, "--server-settings", server_settings, *rest)
|
||||
else:
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
launch()
|
||||
|
||||
112
Fill.py
@@ -1,11 +1,10 @@
|
||||
import logging
|
||||
import typing
|
||||
import collections
|
||||
import itertools
|
||||
import logging
|
||||
import typing
|
||||
from collections import Counter, deque
|
||||
|
||||
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item, ItemClassification
|
||||
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
|
||||
from worlds.AutoWorld import call_all
|
||||
from worlds.generic.Rules import add_item_rule
|
||||
|
||||
@@ -23,15 +22,28 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
||||
|
||||
|
||||
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
||||
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
||||
allow_partial: bool = False) -> None:
|
||||
allow_partial: bool = False, allow_excluded: bool = False) -> None:
|
||||
"""
|
||||
:param world: Multiworld to be filled.
|
||||
:param base_state: State assumed before fill.
|
||||
:param locations: Locations to be filled with item_pool
|
||||
:param item_pool: Items to fill into the locations
|
||||
:param single_player_placement: if true, can speed up placement if everything belongs to a single player
|
||||
:param lock: locations are set to locked as they are filled
|
||||
:param swap: if true, swaps of already place items are done in the event of a dead end
|
||||
:param on_place: callback that is called when a placement happens
|
||||
:param allow_partial: only place what is possible. Remaining items will be in the item_pool list.
|
||||
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
|
||||
"""
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
cleanup_required = False
|
||||
|
||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
|
||||
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
|
||||
for item in itempool:
|
||||
for item in item_pool:
|
||||
reachable_items.setdefault(item.player, deque()).append(item)
|
||||
|
||||
while any(reachable_items.values()) and locations:
|
||||
@@ -39,9 +51,12 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
||||
items_to_place = [items.pop()
|
||||
for items in reachable_items.values() if items]
|
||||
for item in items_to_place:
|
||||
itempool.remove(item)
|
||||
for p, pool_item in enumerate(item_pool):
|
||||
if pool_item is item:
|
||||
item_pool.pop(p)
|
||||
break
|
||||
maximum_exploration_state = sweep_from_pool(
|
||||
base_state, itempool + unplaced_items)
|
||||
base_state, item_pool + unplaced_items)
|
||||
|
||||
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
|
||||
|
||||
@@ -73,25 +88,28 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
||||
else:
|
||||
# we filled all reachable spots.
|
||||
if swap:
|
||||
# try swapping this item with previously placed items
|
||||
for (i, location) in enumerate(placements):
|
||||
# try swapping this item with previously placed items in a safe way then in an unsafe way
|
||||
swap_attempts = ((i, location, unsafe)
|
||||
for unsafe in (False, True)
|
||||
for i, location in enumerate(placements))
|
||||
for (i, location, unsafe) in swap_attempts:
|
||||
placed_item = location.item
|
||||
# Unplaceable items can sometimes be swapped infinitely. Limit the
|
||||
# number of times we will swap an individual item to prevent this
|
||||
swap_count = swapped_items[placed_item.player,
|
||||
placed_item.name]
|
||||
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
|
||||
if swap_count > 1:
|
||||
continue
|
||||
|
||||
location.item = None
|
||||
placed_item.location = None
|
||||
swap_state = sweep_from_pool(base_state, [placed_item])
|
||||
# swap_state assumes we can collect placed item before item_to_place
|
||||
swap_state = sweep_from_pool(base_state, [placed_item] if unsafe else [])
|
||||
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
|
||||
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
|
||||
# to clean that up later, so there is a chance generation fails.
|
||||
if (not single_player_placement or location.player == item_to_place.player) \
|
||||
and location.can_fill(swap_state, item_to_place, perform_access_check):
|
||||
|
||||
# Verify that placing this item won't reduce available locations, which could happen with rules
|
||||
# that want to not have both items. Left in until removal is proven useful.
|
||||
# Verify placing this item won't reduce available locations, which would be a useless swap.
|
||||
prev_state = swap_state.copy()
|
||||
prev_loc_count = len(
|
||||
world.get_reachable_locations(prev_state))
|
||||
@@ -106,12 +124,14 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
||||
spot_to_fill = placements.pop(i)
|
||||
|
||||
swap_count += 1
|
||||
swapped_items[placed_item.player,
|
||||
placed_item.name] = swap_count
|
||||
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
|
||||
|
||||
reachable_items[placed_item.player].appendleft(
|
||||
placed_item)
|
||||
itempool.append(placed_item)
|
||||
item_pool.append(placed_item)
|
||||
|
||||
# cleanup at the end to hopefully get better errors
|
||||
cleanup_required = True
|
||||
|
||||
break
|
||||
|
||||
@@ -133,6 +153,31 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
||||
if on_place:
|
||||
on_place(spot_to_fill)
|
||||
|
||||
if cleanup_required:
|
||||
# validate all placements and remove invalid ones
|
||||
state = sweep_from_pool(base_state, [])
|
||||
for placement in placements:
|
||||
if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state):
|
||||
placement.item.location = None
|
||||
unplaced_items.append(placement.item)
|
||||
placement.item = None
|
||||
locations.append(placement)
|
||||
|
||||
if allow_excluded:
|
||||
# check if partial fill is the result of excluded locations, in which case retry
|
||||
excluded_locations = [
|
||||
location for location in locations
|
||||
if location.progress_type == location.progress_type.EXCLUDED and not location.item
|
||||
]
|
||||
if excluded_locations:
|
||||
for location in excluded_locations:
|
||||
location.progress_type = location.progress_type.DEFAULT
|
||||
fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
|
||||
swap, on_place, allow_partial, False)
|
||||
for location in excluded_locations:
|
||||
if not location.item:
|
||||
location.progress_type = location.progress_type.EXCLUDED
|
||||
|
||||
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0:
|
||||
# There are leftover unplaceable items and locations that won't accept them
|
||||
if world.can_beat_game():
|
||||
@@ -142,7 +187,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
||||
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
|
||||
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
||||
|
||||
itempool.extend(unplaced_items)
|
||||
item_pool.extend(unplaced_items)
|
||||
|
||||
|
||||
def remaining_fill(world: MultiWorld,
|
||||
@@ -499,16 +544,16 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
|
||||
checked_locations: typing.Set[Location] = set()
|
||||
unchecked_locations: typing.Set[Location] = set(world.get_locations())
|
||||
|
||||
reachable_locations_count: typing.Dict[int, int] = {
|
||||
player: 0
|
||||
for player in world.player_ids
|
||||
if len(world.get_filled_locations(player)) != 0
|
||||
}
|
||||
total_locations_count: typing.Counter[int] = Counter(
|
||||
location.player
|
||||
for location in world.get_locations()
|
||||
if not location.locked
|
||||
)
|
||||
reachable_locations_count: typing.Dict[int, int] = {
|
||||
player: 0
|
||||
for player in world.player_ids
|
||||
if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
|
||||
}
|
||||
balanceable_players = {
|
||||
player: balanceable_players[player]
|
||||
for player in balanceable_players
|
||||
@@ -525,6 +570,10 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
|
||||
def item_percentage(player: int, num: int) -> float:
|
||||
return num / total_locations_count[player]
|
||||
|
||||
# If there are no locations that aren't locked, there's no point in attempting to balance progression.
|
||||
if len(total_locations_count) == 0:
|
||||
return
|
||||
|
||||
while True:
|
||||
# Gather non-locked locations.
|
||||
# This ensures that only shuffled locations get counted for progression balancing,
|
||||
@@ -791,13 +840,12 @@ def distribute_planned(world: MultiWorld) -> None:
|
||||
|
||||
if "early_locations" in locations:
|
||||
locations.remove("early_locations")
|
||||
for player in worlds:
|
||||
locations += early_locations[player]
|
||||
for target_player in worlds:
|
||||
locations += early_locations[target_player]
|
||||
if "non_early_locations" in locations:
|
||||
locations.remove("non_early_locations")
|
||||
for player in worlds:
|
||||
locations += non_early_locations[player]
|
||||
|
||||
for target_player in worlds:
|
||||
locations += non_early_locations[target_player]
|
||||
|
||||
block['locations'] = locations
|
||||
|
||||
|
||||
137
Generate.py
@@ -7,55 +7,52 @@ import random
|
||||
import string
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter, ChainMap
|
||||
from typing import Dict, Tuple, Callable, Any, Union
|
||||
from collections import ChainMap, Counter
|
||||
from typing import Any, Callable, Dict, Tuple, Union
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
import copy
|
||||
import Utils
|
||||
from worlds.alttp import Options as LttPOptions
|
||||
from worlds.generic import PlandoConnection
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, user_path
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
import Options
|
||||
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
from Main import main as ERmain
|
||||
from settings import get_settings
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path
|
||||
from worlds.alttp import Options as LttPOptions
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from worlds.alttp.Text import TextTable
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import copy
|
||||
|
||||
|
||||
|
||||
from worlds.generic import PlandoConnection
|
||||
|
||||
|
||||
def mystery_argparse():
|
||||
options = get_options()
|
||||
defaults = options["generator"]
|
||||
|
||||
def resolve_path(path: str, resolver: Callable[[str], str]) -> str:
|
||||
return path if os.path.isabs(path) else resolver(path)
|
||||
options = get_settings()
|
||||
defaults = options.generator
|
||||
|
||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||
parser.add_argument('--weights_file_path', default=defaults["weights_file_path"],
|
||||
parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
|
||||
help='Path to the weights file to use for rolling game settings, urls are also valid')
|
||||
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
|
||||
action='store_true')
|
||||
parser.add_argument('--player_files_path', default=resolve_path(defaults["player_files_path"], user_path),
|
||||
parser.add_argument('--player_files_path', default=defaults.player_files_path,
|
||||
help="Input directory for player files.")
|
||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
||||
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
|
||||
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
|
||||
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
|
||||
parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1))
|
||||
parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
|
||||
parser.add_argument('--outputpath', default=options.general_options.output_path,
|
||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||
parser.add_argument('--race', action='store_true', default=defaults["race"])
|
||||
parser.add_argument('--meta_file_path', default=defaults["meta_file_path"])
|
||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
||||
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
|
||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
||||
parser.add_argument('--plando', default=defaults["plando_options"],
|
||||
parser.add_argument('--plando', default=defaults.plando_options,
|
||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
||||
parser.add_argument("--skip_prog_balancing", action="store_true",
|
||||
help="Skip progression balancing step during generation.")
|
||||
args = parser.parse_args()
|
||||
if not os.path.isabs(args.weights_file_path):
|
||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||
@@ -72,12 +69,16 @@ def get_seed_name(random_source) -> str:
|
||||
def main(args=None, callback=ERmain):
|
||||
if not args:
|
||||
args, options = mystery_argparse()
|
||||
else:
|
||||
options = get_settings()
|
||||
|
||||
seed = get_seed(args.seed)
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||
random.seed(seed)
|
||||
seed_name = get_seed_name(random)
|
||||
|
||||
if args.race:
|
||||
logging.info("Race mode enabled. Using non-deterministic random source.")
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
||||
@@ -85,16 +86,16 @@ def main(args=None, callback=ERmain):
|
||||
try:
|
||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
|
||||
print(f"Weights: {args.weights_file_path} >> "
|
||||
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
|
||||
raise ValueError(f"File {args.weights_file_path} is invalid. Please fix your yaml.") from e
|
||||
logging.info(f"Weights: {args.weights_file_path} >> "
|
||||
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
|
||||
|
||||
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
||||
try:
|
||||
meta_weights = read_weights_yamls(args.meta_file_path)[-1]
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
|
||||
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
||||
raise ValueError(f"File {args.meta_file_path} is invalid. Please fix your yaml.") from e
|
||||
logging.info(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
||||
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
|
||||
del(meta_weights["meta_description"])
|
||||
except Exception as e:
|
||||
@@ -113,35 +114,35 @@ def main(args=None, callback=ERmain):
|
||||
try:
|
||||
weights_cache[fname] = read_weights_yamls(path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e
|
||||
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
|
||||
|
||||
# sort dict for consistent results across platforms:
|
||||
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
||||
for filename, yaml_data in weights_cache.items():
|
||||
if filename not in {args.meta_file_path, args.weights_file_path}:
|
||||
for yaml in yaml_data:
|
||||
print(f"P{player_id} Weights: {filename} >> "
|
||||
f"{get_choice('description', yaml, 'No description specified')}")
|
||||
logging.info(f"P{player_id} Weights: {filename} >> "
|
||||
f"{get_choice('description', yaml, 'No description specified')}")
|
||||
player_files[player_id] = filename
|
||||
player_id += 1
|
||||
|
||||
args.multi = max(player_id - 1, args.multi)
|
||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
||||
f"{args.plando}")
|
||||
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
|
||||
f"{seed_name} Seed {seed} with plando: {args.plando}")
|
||||
|
||||
if not weights_cache:
|
||||
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||
raise Exception(f"No weights found. "
|
||||
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||
f"A mix is also permitted.")
|
||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||
erargs.seed = seed
|
||||
erargs.plando_options = args.plando
|
||||
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
|
||||
erargs.glitch_triforce = options.generator.glitch_triforce_room
|
||||
erargs.spoiler = args.spoiler
|
||||
erargs.race = args.race
|
||||
erargs.outputname = seed_name
|
||||
erargs.outputpath = args.outputpath
|
||||
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||
erargs.skip_prog_balancing = args.skip_prog_balancing
|
||||
|
||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
|
||||
@@ -194,7 +195,7 @@ def main(args=None, callback=ERmain):
|
||||
|
||||
player += 1
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||
raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e
|
||||
else:
|
||||
raise RuntimeError(f'No weights specified for player {player}')
|
||||
|
||||
@@ -373,7 +374,7 @@ def roll_linked_options(weights: dict) -> dict:
|
||||
else:
|
||||
logging.debug(f"linked option {option_set['name']} skipped.")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Linked option {option_set['name']} is destroyed. "
|
||||
raise ValueError(f"Linked option {option_set['name']} is invalid. "
|
||||
f"Please fix your linked option.") from e
|
||||
return weights
|
||||
|
||||
@@ -403,7 +404,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Your trigger number {i + 1} is destroyed. "
|
||||
raise ValueError(f"Your trigger number {i + 1} is invalid. "
|
||||
f"Please fix your triggers.") from e
|
||||
return weights
|
||||
|
||||
@@ -449,6 +450,11 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
|
||||
|
||||
ret.game = get_choice("game", weights)
|
||||
if ret.game not in AutoWorldRegister.world_types:
|
||||
picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0]
|
||||
raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? "
|
||||
f"Check your spelling or installation of that world.")
|
||||
|
||||
if ret.game not in weights:
|
||||
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
|
||||
|
||||
@@ -463,32 +469,29 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
for option_key, option in Options.common_options.items():
|
||||
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
|
||||
|
||||
if ret.game in AutoWorldRegister.world_types:
|
||||
for option_key, option in world_type.option_definitions.items():
|
||||
for option_key, option in world_type.option_definitions.items():
|
||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||
for option_key, option in Options.per_game_common_options.items():
|
||||
# skip setting this option if already set from common_options, defaulting to root option
|
||||
if option_key not in world_type.option_definitions and \
|
||||
(option_key not in Options.common_options or option_key in game_weights):
|
||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||
for option_key, option in Options.per_game_common_options.items():
|
||||
# skip setting this option if already set from common_options, defaulting to root option
|
||||
if option_key not in world_type.option_definitions and \
|
||||
(option_key not in Options.common_options or option_key in game_weights):
|
||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||
if PlandoOptions.items in plando_options:
|
||||
ret.plando_items = game_weights.get("plando_items", [])
|
||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||
# bad hardcoded behavior to make this work for now
|
||||
ret.plando_connections = []
|
||||
if PlandoOptions.connections in plando_options:
|
||||
options = game_weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice("entrance", placement),
|
||||
get_choice("exit", placement),
|
||||
get_choice("direction", placement)
|
||||
))
|
||||
elif ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, game_weights, plando_options)
|
||||
else:
|
||||
raise Exception(f"Unsupported game {ret.game}")
|
||||
if PlandoOptions.items in plando_options:
|
||||
ret.plando_items = game_weights.get("plando_items", [])
|
||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||
# bad hardcoded behavior to make this work for now
|
||||
ret.plando_connections = []
|
||||
if PlandoOptions.connections in plando_options:
|
||||
options = game_weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice("entrance", placement),
|
||||
get_choice("exit", placement),
|
||||
get_choice("direction", placement)
|
||||
))
|
||||
elif ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, game_weights, plando_options)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
894
KH2Client.py
Normal file
@@ -0,0 +1,894 @@
|
||||
import os
|
||||
import asyncio
|
||||
import ModuleUpdate
|
||||
import json
|
||||
import Utils
|
||||
from pymem import pymem
|
||||
from worlds.kh2.Items import exclusionItem_table, CheckDupingItems
|
||||
from worlds.kh2 import all_locations, item_dictionary_table, exclusion_table
|
||||
|
||||
from worlds.kh2.WorldLocations import *
|
||||
|
||||
from worlds import network_data_package
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("KH2Client", exception_logger="Client")
|
||||
|
||||
from NetUtils import ClientStatus
|
||||
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
||||
CommonContext, server_loop
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
kh2_loc_name_to_id = network_data_package["games"]["Kingdom Hearts 2"]["location_name_to_id"]
|
||||
|
||||
|
||||
# class KH2CommandProcessor(ClientCommandProcessor):
|
||||
|
||||
|
||||
class KH2Context(CommonContext):
|
||||
# command_processor: int = KH2CommandProcessor
|
||||
game = "Kingdom Hearts 2"
|
||||
items_handling = 0b101 # Indicates you get items sent from other worlds.
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(KH2Context, self).__init__(server_address, password)
|
||||
self.kh2LocalItems = None
|
||||
self.ability = None
|
||||
self.growthlevel = None
|
||||
self.KH2_sync_task = None
|
||||
self.syncing = False
|
||||
self.kh2connected = False
|
||||
self.serverconneced = False
|
||||
self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()}
|
||||
self.location_name_to_data = {name: data for name, data, in all_locations.items()}
|
||||
self.lookup_id_to_item: typing.Dict[int, str] = {data.code: item_name for item_name, data in
|
||||
item_dictionary_table.items() if data.code}
|
||||
self.lookup_id_to_Location: typing.Dict[int, str] = {data.code: item_name for item_name, data in
|
||||
all_locations.items() if data.code}
|
||||
self.location_name_to_worlddata = {name: data for name, data, in all_world_locations.items()}
|
||||
|
||||
self.location_table = {}
|
||||
self.collectible_table = {}
|
||||
self.collectible_override_flags_address = 0
|
||||
self.collectible_offsets = {}
|
||||
self.sending = []
|
||||
# list used to keep track of locations+items player has. Used for disoneccting
|
||||
self.kh2seedsave = None
|
||||
self.slotDataProgressionNames = {}
|
||||
self.kh2seedname = None
|
||||
self.kh2slotdata = None
|
||||
self.itemamount = {}
|
||||
# sora equipped, valor equipped, master equipped, final equipped
|
||||
self.keybladeAnchorList = (0x24F0, 0x32F4, 0x339C, 0x33D4)
|
||||
if "localappdata" in os.environ:
|
||||
self.game_communication_path = os.path.expandvars(r"%localappdata%\KH2AP")
|
||||
self.amountOfPieces = 0
|
||||
# hooked object
|
||||
self.kh2 = None
|
||||
self.ItemIsSafe = False
|
||||
self.game_connected = False
|
||||
self.finalxemnas = False
|
||||
self.worldid = {
|
||||
# 1: {}, # world of darkness (story cutscenes)
|
||||
2: TT_Checks,
|
||||
# 3: {}, # destiny island doesn't have checks to ima put tt checks here
|
||||
4: HB_Checks,
|
||||
5: BC_Checks,
|
||||
6: Oc_Checks,
|
||||
7: AG_Checks,
|
||||
8: LoD_Checks,
|
||||
9: HundredAcreChecks,
|
||||
10: PL_Checks,
|
||||
11: DC_Checks, # atlantica isn't a supported world. if you go in atlantica it will check dc
|
||||
12: DC_Checks,
|
||||
13: TR_Checks,
|
||||
14: HT_Checks,
|
||||
15: HB_Checks, # world map, but you only go to the world map while on the way to goa so checking hb
|
||||
16: PR_Checks,
|
||||
17: SP_Checks,
|
||||
18: TWTNW_Checks,
|
||||
# 255: {}, # starting screen
|
||||
}
|
||||
# 0x2A09C00+0x40 is the sve anchor. +1 is the last saved room
|
||||
self.sveroom = 0x2A09C00 + 0x41
|
||||
# 0 not in battle 1 in yellow battle 2 red battle #short
|
||||
self.inBattle = 0x2A0EAC4 + 0x40
|
||||
self.onDeath = 0xAB9078
|
||||
# PC Address anchors
|
||||
self.Now = 0x0714DB8
|
||||
self.Save = 0x09A70B0
|
||||
self.Sys3 = 0x2A59DF0
|
||||
self.Bt10 = 0x2A74880
|
||||
self.BtlEnd = 0x2A0D3E0
|
||||
self.Slot1 = 0x2A20C98
|
||||
|
||||
self.chest_set = set(exclusion_table["Chests"])
|
||||
|
||||
self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"])
|
||||
self.staff_set = set(CheckDupingItems["Weapons"]["Staffs"])
|
||||
self.shield_set = set(CheckDupingItems["Weapons"]["Shields"])
|
||||
|
||||
self.all_weapons = self.keyblade_set.union(self.staff_set).union(self.shield_set)
|
||||
|
||||
self.equipment_categories = CheckDupingItems["Equipment"]
|
||||
self.armor_set = set(self.equipment_categories["Armor"])
|
||||
self.accessories_set = set(self.equipment_categories["Accessories"])
|
||||
self.all_equipment = self.armor_set.union(self.accessories_set)
|
||||
|
||||
self.Equipment_Anchor_Dict = {
|
||||
"Armor": [0x2504, 0x2506, 0x2508, 0x250A],
|
||||
"Accessories": [0x2514, 0x2516, 0x2518, 0x251A]}
|
||||
|
||||
self.AbilityQuantityDict = {}
|
||||
self.ability_categories = CheckDupingItems["Abilities"]
|
||||
|
||||
self.sora_ability_set = set(self.ability_categories["Sora"])
|
||||
self.donald_ability_set = set(self.ability_categories["Donald"])
|
||||
self.goofy_ability_set = set(self.ability_categories["Goofy"])
|
||||
|
||||
self.all_abilities = self.sora_ability_set.union(self.donald_ability_set).union(self.goofy_ability_set)
|
||||
|
||||
self.boost_set = set(CheckDupingItems["Boosts"])
|
||||
self.stat_increase_set = set(CheckDupingItems["Stat Increases"])
|
||||
self.AbilityQuantityDict = {item: self.item_name_to_data[item].quantity for item in self.all_abilities}
|
||||
# Growth:[level 1,level 4,slot]
|
||||
self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25DA],
|
||||
"Quick Run": [0x62, 0x65, 0x25DC],
|
||||
"Dodge Roll": [0x234, 0x237, 0x25DE],
|
||||
"Aerial Dodge": [0x066, 0x069, 0x25E0],
|
||||
"Glide": [0x6A, 0x6D, 0x25E2]}
|
||||
self.boost_to_anchor_dict = {
|
||||
"Power Boost": 0x24F9,
|
||||
"Magic Boost": 0x24FA,
|
||||
"Defense Boost": 0x24FB,
|
||||
"AP Boost": 0x24F8}
|
||||
|
||||
self.AbilityCodeList = [self.item_name_to_data[item].code for item in exclusionItem_table["Ability"]]
|
||||
self.master_growth = {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}
|
||||
|
||||
self.bitmask_item_code = [
|
||||
0x130000, 0x130001, 0x130002, 0x130003, 0x130004, 0x130005, 0x130006, 0x130007
|
||||
, 0x130008, 0x130009, 0x13000A, 0x13000B, 0x13000C
|
||||
, 0x13001F, 0x130020, 0x130021, 0x130022, 0x130023
|
||||
, 0x13002A, 0x13002B, 0x13002C, 0x13002D]
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(KH2Context, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
async def connection_closed(self):
|
||||
self.kh2connected = False
|
||||
self.serverconneced = False
|
||||
if self.kh2seedname is not None and self.auth is not None:
|
||||
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
|
||||
'w') as f:
|
||||
f.write(json.dumps(self.kh2seedsave, indent=4))
|
||||
await super(KH2Context, self).connection_closed()
|
||||
|
||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||
self.kh2connected = False
|
||||
self.serverconneced = False
|
||||
if self.kh2seedname not in {None} and self.auth not in {None}:
|
||||
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
|
||||
'w') as f:
|
||||
f.write(json.dumps(self.kh2seedsave, indent=4))
|
||||
await super(KH2Context, self).disconnect()
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
if self.server:
|
||||
return [self.server]
|
||||
else:
|
||||
return []
|
||||
|
||||
async def shutdown(self):
|
||||
if self.kh2seedname not in {None} and self.auth not in {None}:
|
||||
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
|
||||
'w') as f:
|
||||
f.write(json.dumps(self.kh2seedsave, indent=4))
|
||||
await super(KH2Context, self).shutdown()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"RoomInfo"}:
|
||||
self.kh2seedname = args['seed_name']
|
||||
if not os.path.exists(self.game_communication_path):
|
||||
os.makedirs(self.game_communication_path)
|
||||
if not os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
|
||||
self.kh2seedsave = {"itemIndex": -1,
|
||||
# back of soras invo is 0x25E2. Growth should be moved there
|
||||
# Character: [back of invo, front of invo]
|
||||
"SoraInvo": [0x25D8, 0x2546],
|
||||
"DonaldInvo": [0x26F4, 0x2658],
|
||||
"GoofyInvo": [0x280A, 0x276C],
|
||||
"AmountInvo": {
|
||||
"ServerItems": {
|
||||
"Ability": {},
|
||||
"Amount": {},
|
||||
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
|
||||
"Aerial Dodge": 0,
|
||||
"Glide": 0},
|
||||
"Bitmask": [],
|
||||
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
|
||||
"Equipment": [],
|
||||
"Magic": {},
|
||||
"StatIncrease": {},
|
||||
"Boost": {},
|
||||
},
|
||||
"LocalItems": {
|
||||
"Ability": {},
|
||||
"Amount": {},
|
||||
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
|
||||
"Aerial Dodge": 0, "Glide": 0},
|
||||
"Bitmask": [],
|
||||
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
|
||||
"Equipment": [],
|
||||
"Magic": {},
|
||||
"StatIncrease": {},
|
||||
"Boost": {},
|
||||
}},
|
||||
# 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked
|
||||
"LocationsChecked": [],
|
||||
"Levels": {
|
||||
"SoraLevel": 0,
|
||||
"ValorLevel": 0,
|
||||
"WisdomLevel": 0,
|
||||
"LimitLevel": 0,
|
||||
"MasterLevel": 0,
|
||||
"FinalLevel": 0,
|
||||
},
|
||||
"SoldEquipment": [],
|
||||
"SoldBoosts": {"Power Boost": 0,
|
||||
"Magic Boost": 0,
|
||||
"Defense Boost": 0,
|
||||
"AP Boost": 0}
|
||||
}
|
||||
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
|
||||
'wt') as f:
|
||||
pass
|
||||
self.locations_checked = set()
|
||||
elif os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
|
||||
with open(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json", 'r') as f:
|
||||
self.kh2seedsave = json.load(f)
|
||||
self.locations_checked = set(self.kh2seedsave["LocationsChecked"])
|
||||
self.serverconneced = True
|
||||
|
||||
if cmd in {"Connected"}:
|
||||
self.kh2slotdata = args['slot_data']
|
||||
self.kh2LocalItems = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()}
|
||||
try:
|
||||
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
|
||||
logger.info("You are now auto-tracking")
|
||||
self.kh2connected = True
|
||||
except Exception as e:
|
||||
logger.info("Line 247")
|
||||
if self.kh2connected:
|
||||
logger.info("Connection Lost")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
if cmd in {"ReceivedItems"}:
|
||||
start_index = args["index"]
|
||||
if start_index == 0:
|
||||
# resetting everything that were sent from the server
|
||||
self.kh2seedsave["SoraInvo"][0] = 0x25D8
|
||||
self.kh2seedsave["DonaldInvo"][0] = 0x26F4
|
||||
self.kh2seedsave["GoofyInvo"][0] = 0x280A
|
||||
self.kh2seedsave["itemIndex"] = - 1
|
||||
self.kh2seedsave["AmountInvo"]["ServerItems"] = {
|
||||
"Ability": {},
|
||||
"Amount": {},
|
||||
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
|
||||
"Aerial Dodge": 0,
|
||||
"Glide": 0},
|
||||
"Bitmask": [],
|
||||
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
|
||||
"Equipment": [],
|
||||
"Magic": {},
|
||||
"StatIncrease": {},
|
||||
"Boost": {},
|
||||
}
|
||||
if start_index > self.kh2seedsave["itemIndex"]:
|
||||
self.kh2seedsave["itemIndex"] = start_index
|
||||
for item in args['items']:
|
||||
asyncio.create_task(self.give_item(item.item))
|
||||
|
||||
if cmd in {"RoomUpdate"}:
|
||||
if "checked_locations" in args:
|
||||
new_locations = set(args["checked_locations"])
|
||||
# TODO: make this take locations from other players on the same slot so proper coop happens
|
||||
# items_to_give = [self.kh2slotdata["LocalItems"][str(location_id)] for location_id in new_locations if
|
||||
# location_id in self.kh2LocalItems.keys()]
|
||||
self.checked_locations |= new_locations
|
||||
|
||||
async def checkWorldLocations(self):
|
||||
try:
|
||||
currentworldint = int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big")
|
||||
if currentworldint in self.worldid:
|
||||
curworldid = self.worldid[currentworldint]
|
||||
for location, data in curworldid.items():
|
||||
locationId = kh2_loc_name_to_id[location]
|
||||
if locationId not in self.locations_checked \
|
||||
and (int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
|
||||
"big") & 0x1 << data.bitIndex) > 0:
|
||||
self.sending = self.sending + [(int(locationId))]
|
||||
except Exception as e:
|
||||
logger.info("Line 285")
|
||||
if self.kh2connected:
|
||||
logger.info("Connection Lost.")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
async def checkLevels(self):
|
||||
try:
|
||||
for location, data in SoraLevels.items():
|
||||
currentLevel = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), "big")
|
||||
locationId = kh2_loc_name_to_id[location]
|
||||
if locationId not in self.locations_checked \
|
||||
and currentLevel >= data.bitIndex:
|
||||
if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel:
|
||||
self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel
|
||||
self.sending = self.sending + [(int(locationId))]
|
||||
formDict = {
|
||||
0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels],
|
||||
3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels]}
|
||||
for i in range(5):
|
||||
for location, data in formDict[i][1].items():
|
||||
formlevel = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big")
|
||||
locationId = kh2_loc_name_to_id[location]
|
||||
if locationId not in self.locations_checked \
|
||||
and formlevel >= data.bitIndex:
|
||||
if formlevel > self.kh2seedsave["Levels"][formDict[i][0]]:
|
||||
self.kh2seedsave["Levels"][formDict[i][0]] = formlevel
|
||||
self.sending = self.sending + [(int(locationId))]
|
||||
except Exception as e:
|
||||
logger.info("Line 312")
|
||||
if self.kh2connected:
|
||||
logger.info("Connection Lost.")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
async def checkSlots(self):
|
||||
try:
|
||||
for location, data in weaponSlots.items():
|
||||
locationId = kh2_loc_name_to_id[location]
|
||||
if locationId not in self.locations_checked:
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
|
||||
"big") > 0:
|
||||
self.sending = self.sending + [(int(locationId))]
|
||||
|
||||
for location, data in formSlots.items():
|
||||
locationId = kh2_loc_name_to_id[location]
|
||||
if locationId not in self.locations_checked:
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
|
||||
"big") & 0x1 << data.bitIndex > 0:
|
||||
# self.locations_checked
|
||||
self.sending = self.sending + [(int(locationId))]
|
||||
|
||||
except Exception as e:
|
||||
if self.kh2connected:
|
||||
logger.info("Line 333")
|
||||
logger.info("Connection Lost.")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
async def verifyChests(self):
|
||||
try:
|
||||
for location in self.locations_checked:
|
||||
locationName = self.lookup_id_to_Location[location]
|
||||
if locationName in self.chest_set:
|
||||
if locationName in self.location_name_to_worlddata.keys():
|
||||
locationData = self.location_name_to_worlddata[locationName]
|
||||
if int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
|
||||
"big") & 0x1 << locationData.bitIndex == 0:
|
||||
roomData = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
|
||||
1), "big")
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
|
||||
(roomData | 0x01 << locationData.bitIndex).to_bytes(1, 'big'), 1)
|
||||
|
||||
except Exception as e:
|
||||
if self.kh2connected:
|
||||
logger.info("Line 350")
|
||||
logger.info("Connection Lost.")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
async def verifyLevel(self):
|
||||
for leveltype, anchor in {"SoraLevel": 0x24FF,
|
||||
"ValorLevel": 0x32F6,
|
||||
"WisdomLevel": 0x332E,
|
||||
"LimitLevel": 0x3366,
|
||||
"MasterLevel": 0x339E,
|
||||
"FinalLevel": 0x33D6}.items():
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + anchor, 1), "big") < \
|
||||
self.kh2seedsave["Levels"][leveltype]:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + anchor,
|
||||
(self.kh2seedsave["Levels"][leveltype]).to_bytes(1, 'big'), 1)
|
||||
|
||||
async def give_item(self, item, ItemType="ServerItems"):
|
||||
try:
|
||||
itemname = self.lookup_id_to_item[item]
|
||||
itemcode = self.item_name_to_data[itemname]
|
||||
if itemcode.ability:
|
||||
abilityInvoType = 0
|
||||
TwilightZone = 2
|
||||
if ItemType == "LocalItems":
|
||||
abilityInvoType = 1
|
||||
TwilightZone = -2
|
||||
if itemname in {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Growth"][itemname] += 1
|
||||
return
|
||||
|
||||
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Ability"]:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname] = []
|
||||
# appending the slot that the ability should be in
|
||||
|
||||
if len(self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname]) < \
|
||||
self.AbilityQuantityDict[itemname]:
|
||||
if itemname in self.sora_ability_set:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
|
||||
self.kh2seedsave["SoraInvo"][abilityInvoType])
|
||||
self.kh2seedsave["SoraInvo"][abilityInvoType] -= TwilightZone
|
||||
elif itemname in self.donald_ability_set:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
|
||||
self.kh2seedsave["DonaldInvo"][abilityInvoType])
|
||||
self.kh2seedsave["DonaldInvo"][abilityInvoType] -= TwilightZone
|
||||
else:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
|
||||
self.kh2seedsave["GoofyInvo"][abilityInvoType])
|
||||
self.kh2seedsave["GoofyInvo"][abilityInvoType] -= TwilightZone
|
||||
|
||||
elif itemcode.code in self.bitmask_item_code:
|
||||
|
||||
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"]:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"].append(itemname)
|
||||
|
||||
elif itemcode.memaddr in {0x3594, 0x3595, 0x3596, 0x3597, 0x35CF, 0x35D0}:
|
||||
|
||||
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Magic"]:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] += 1
|
||||
else:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] = 1
|
||||
elif itemname in self.all_equipment:
|
||||
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Equipment"].append(itemname)
|
||||
|
||||
elif itemname in self.all_weapons:
|
||||
if itemname in self.keyblade_set:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Sora"].append(itemname)
|
||||
elif itemname in self.staff_set:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Donald"].append(itemname)
|
||||
else:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Goofy"].append(itemname)
|
||||
|
||||
elif itemname in self.boost_set:
|
||||
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Boost"]:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] += 1
|
||||
else:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] = 1
|
||||
|
||||
elif itemname in self.stat_increase_set:
|
||||
|
||||
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"]:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] += 1
|
||||
else:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] = 1
|
||||
|
||||
else:
|
||||
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Amount"]:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] += 1
|
||||
else:
|
||||
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] = 1
|
||||
|
||||
except Exception as e:
|
||||
if self.kh2connected:
|
||||
logger.info("Line 398")
|
||||
logger.info("Connection Lost.")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager
|
||||
|
||||
class KH2Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago KH2 Client"
|
||||
|
||||
self.ui = KH2Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
async def IsInShop(self, sellable, master_boost):
|
||||
# journal = 0x741230 shop = 0x741320
|
||||
# if journal=-1 and shop = 5 then in shop
|
||||
# if journam !=-1 and shop = 10 then journal
|
||||
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
|
||||
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
|
||||
if (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
|
||||
# print("your in the shop")
|
||||
sellable_dict = {}
|
||||
for itemName in sellable:
|
||||
itemdata = self.item_name_to_data[itemName]
|
||||
amount = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
|
||||
sellable_dict[itemName] = amount
|
||||
while (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
|
||||
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
|
||||
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
|
||||
await asyncio.sleep(0.5)
|
||||
for item, amount in sellable_dict.items():
|
||||
itemdata = self.item_name_to_data[item]
|
||||
afterShop = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
|
||||
if afterShop < amount:
|
||||
if item in master_boost:
|
||||
self.kh2seedsave["SoldBoosts"][item] += (amount - afterShop)
|
||||
else:
|
||||
self.kh2seedsave["SoldEquipment"].append(item)
|
||||
|
||||
async def verifyItems(self):
|
||||
try:
|
||||
local_amount = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"].keys())
|
||||
server_amount = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"].keys())
|
||||
master_amount = local_amount | server_amount
|
||||
|
||||
local_ability = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"].keys())
|
||||
server_ability = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"].keys())
|
||||
master_ability = local_ability | server_ability
|
||||
|
||||
local_bitmask = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Bitmask"])
|
||||
server_bitmask = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Bitmask"])
|
||||
master_bitmask = local_bitmask | server_bitmask
|
||||
|
||||
local_keyblade = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Sora"])
|
||||
local_staff = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Donald"])
|
||||
local_shield = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Goofy"])
|
||||
|
||||
server_keyblade = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Sora"])
|
||||
server_staff = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Donald"])
|
||||
server_shield = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Goofy"])
|
||||
|
||||
master_keyblade = local_keyblade | server_keyblade
|
||||
master_staff = local_staff | server_staff
|
||||
master_shield = local_shield | server_shield
|
||||
|
||||
local_equipment = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Equipment"])
|
||||
server_equipment = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Equipment"])
|
||||
master_equipment = local_equipment | server_equipment
|
||||
|
||||
local_magic = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"].keys())
|
||||
server_magic = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"].keys())
|
||||
master_magic = local_magic | server_magic
|
||||
|
||||
local_stat = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"].keys())
|
||||
server_stat = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"].keys())
|
||||
master_stat = local_stat | server_stat
|
||||
|
||||
local_boost = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"].keys())
|
||||
server_boost = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"].keys())
|
||||
master_boost = local_boost | server_boost
|
||||
|
||||
master_sell = master_equipment | master_staff | master_shield | master_boost
|
||||
await asyncio.create_task(self.IsInShop(master_sell, master_boost))
|
||||
for itemName in master_amount:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
amountOfItems = 0
|
||||
if itemName in local_amount:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"][itemName]
|
||||
if itemName in server_amount:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"][itemName]
|
||||
|
||||
if itemName == "Torn Page":
|
||||
# Torn Pages are handled differently because they can be consumed.
|
||||
# Will check the progression in 100 acre and - the amount of visits
|
||||
# amountofitems-amount of visits done
|
||||
for location, data in tornPageLocks.items():
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
|
||||
"big") & 0x1 << data.bitIndex > 0:
|
||||
amountOfItems -= 1
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != amountOfItems and amountOfItems >= 0:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
amountOfItems.to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_keyblade:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
# if the inventory slot for that keyblade is less than the amount they should have
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != 1 and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x1CFF, 1),
|
||||
"big") != 13:
|
||||
# Checking form anchors for the keyblade
|
||||
if self.kh2.read_short(self.kh2.base_address + self.Save + 0x24F0) == itemData.kh2id \
|
||||
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x32F4) == itemData.kh2id \
|
||||
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x339C) == itemData.kh2id \
|
||||
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x33D4) == itemData.kh2id:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(0).to_bytes(1, 'big'), 1)
|
||||
else:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(1).to_bytes(1, 'big'), 1)
|
||||
for itemName in master_staff:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != 1 \
|
||||
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2604) != itemData.kh2id \
|
||||
and itemName not in self.kh2seedsave["SoldEquipment"]:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(1).to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_shield:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != 1 \
|
||||
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2718) != itemData.kh2id \
|
||||
and itemName not in self.kh2seedsave["SoldEquipment"]:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(1).to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_ability:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
ability_slot = []
|
||||
if itemName in local_ability:
|
||||
ability_slot += self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"][itemName]
|
||||
if itemName in server_ability:
|
||||
ability_slot += self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"][itemName]
|
||||
for slot in ability_slot:
|
||||
current = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
|
||||
ability = current & 0x0FFF
|
||||
if ability | 0x8000 != (0x8000 + itemData.memaddr):
|
||||
if current - 0x8000 > 0:
|
||||
self.kh2.write_short(self.kh2.base_address + self.Save + slot, (0x8000 + itemData.memaddr))
|
||||
else:
|
||||
self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr)
|
||||
# removes the duped ability if client gave faster than the game.
|
||||
for charInvo in {"SoraInvo", "DonaldInvo", "GoofyInvo"}:
|
||||
if self.kh2.read_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1]) != 0 and \
|
||||
self.kh2seedsave[charInvo][1] + 2 < self.kh2seedsave[charInvo][0]:
|
||||
self.kh2.write_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1], 0)
|
||||
# remove the dummy level 1 growths if they are in these invo slots.
|
||||
for inventorySlot in {0x25CE, 0x25D0, 0x25D2, 0x25D4, 0x25D6, 0x25D8}:
|
||||
current = self.kh2.read_short(self.kh2.base_address + self.Save + inventorySlot)
|
||||
ability = current & 0x0FFF
|
||||
if 0x05E <= ability <= 0x06D:
|
||||
self.kh2.write_short(self.kh2.base_address + self.Save + inventorySlot, 0)
|
||||
|
||||
for itemName in self.master_growth:
|
||||
growthLevel = self.kh2seedsave["AmountInvo"]["ServerItems"]["Growth"][itemName] \
|
||||
+ self.kh2seedsave["AmountInvo"]["LocalItems"]["Growth"][itemName]
|
||||
if growthLevel > 0:
|
||||
slot = self.growth_values_dict[itemName][2]
|
||||
min_growth = self.growth_values_dict[itemName][0]
|
||||
max_growth = self.growth_values_dict[itemName][1]
|
||||
if growthLevel > 4:
|
||||
growthLevel = 4
|
||||
current_growth_level = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
|
||||
ability = current_growth_level & 0x0FFF
|
||||
# if the player should be getting a growth ability
|
||||
if ability | 0x8000 != 0x8000 + min_growth - 1 + growthLevel:
|
||||
# if it should be level one of that growth
|
||||
if 0x8000 + min_growth - 1 + growthLevel <= 0x8000 + min_growth or ability < min_growth:
|
||||
self.kh2.write_short(self.kh2.base_address + self.Save + slot, min_growth)
|
||||
# if it is already in the inventory
|
||||
elif ability | 0x8000 < (0x8000 + max_growth):
|
||||
self.kh2.write_short(self.kh2.base_address + self.Save + slot, current_growth_level + 1)
|
||||
|
||||
for itemName in master_bitmask:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
itemMemory = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big")
|
||||
if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") & 0x1 << itemData.bitmask) == 0:
|
||||
# when getting a form anti points should be reset to 0 but bit-shift doesn't trigger the game.
|
||||
if itemName in {"Valor Form", "Wisdom Form", "Limit Form", "Master Form", "Final Form"}:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + 0x3410,
|
||||
(0).to_bytes(1, 'big'), 1)
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(itemMemory | 0x01 << itemData.bitmask).to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_equipment:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
isThere = False
|
||||
if itemName in self.accessories_set:
|
||||
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Accessories"]
|
||||
else:
|
||||
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Armor"]
|
||||
# Checking form anchors for the equipment
|
||||
for slot in Equipment_Anchor_List:
|
||||
if self.kh2.read_short(self.kh2.base_address + self.Save + slot) == itemData.kh2id:
|
||||
isThere = True
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != 0:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(0).to_bytes(1, 'big'), 1)
|
||||
break
|
||||
if not isThere and itemName not in self.kh2seedsave["SoldEquipment"]:
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != 1:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(1).to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_magic:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
amountOfItems = 0
|
||||
if itemName in local_magic:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"][itemName]
|
||||
if itemName in server_magic:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"][itemName]
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != amountOfItems \
|
||||
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x741320, 1), "big") in {10, 8}:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
amountOfItems.to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_stat:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
amountOfItems = 0
|
||||
if itemName in local_stat:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"][itemName]
|
||||
if itemName in server_stat:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"][itemName]
|
||||
|
||||
# 0x130293 is Crit_1's location id for touching the computer
|
||||
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big") != amountOfItems \
|
||||
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Slot1 + 0x1B2, 1),
|
||||
"big") >= 5 and int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x23DF, 1),
|
||||
"big") > 0:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
amountOfItems.to_bytes(1, 'big'), 1)
|
||||
|
||||
for itemName in master_boost:
|
||||
itemData = self.item_name_to_data[itemName]
|
||||
amountOfItems = 0
|
||||
if itemName in local_boost:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"][itemName]
|
||||
if itemName in server_boost:
|
||||
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"][itemName]
|
||||
amountOfBoostsInInvo = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
|
||||
"big")
|
||||
amountOfUsedBoosts = int.from_bytes(
|
||||
self.kh2.read_bytes(self.kh2.base_address + self.Save + self.boost_to_anchor_dict[itemName], 1),
|
||||
"big")
|
||||
# Ap Boots start at +50 for some reason
|
||||
if itemName == "AP Boost":
|
||||
amountOfUsedBoosts -= 50
|
||||
totalBoosts = (amountOfBoostsInInvo + amountOfUsedBoosts)
|
||||
if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][
|
||||
itemName] and amountOfBoostsInInvo < 255:
|
||||
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
|
||||
(amountOfBoostsInInvo + 1).to_bytes(1, 'big'), 1)
|
||||
|
||||
except Exception as e:
|
||||
logger.info("Line 573")
|
||||
if self.kh2connected:
|
||||
logger.info("Connection Lost.")
|
||||
self.kh2connected = False
|
||||
logger.info(e)
|
||||
|
||||
|
||||
def finishedGame(ctx: KH2Context, message):
|
||||
if ctx.kh2slotdata['FinalXemnas'] == 1:
|
||||
if 0x1301ED in message[0]["locations"]:
|
||||
ctx.finalxemnas = True
|
||||
# three proofs
|
||||
if ctx.kh2slotdata['Goal'] == 0:
|
||||
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, 1), "big") > 0 \
|
||||
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, 1), "big") > 0 \
|
||||
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, 1), "big") > 0:
|
||||
if ctx.kh2slotdata['FinalXemnas'] == 1:
|
||||
if ctx.finalxemnas:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
elif ctx.kh2slotdata['Goal'] == 1:
|
||||
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x3641, 1), "big") >= \
|
||||
ctx.kh2slotdata['LuckyEmblemsRequired']:
|
||||
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
|
||||
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
|
||||
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
|
||||
if ctx.kh2slotdata['FinalXemnas'] == 1:
|
||||
if ctx.finalxemnas:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
elif ctx.kh2slotdata['Goal'] == 2:
|
||||
for boss in ctx.kh2slotdata["hitlist"]:
|
||||
if boss in message[0]["locations"]:
|
||||
ctx.amountOfPieces += 1
|
||||
if ctx.amountOfPieces >= ctx.kh2slotdata["BountyRequired"]:
|
||||
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
|
||||
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
|
||||
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
|
||||
if ctx.kh2slotdata['FinalXemnas'] == 1:
|
||||
if ctx.finalxemnas:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
async def kh2_watcher(ctx: KH2Context):
|
||||
while not ctx.exit_event.is_set():
|
||||
try:
|
||||
if ctx.kh2connected and ctx.serverconneced:
|
||||
ctx.sending = []
|
||||
await asyncio.create_task(ctx.checkWorldLocations())
|
||||
await asyncio.create_task(ctx.checkLevels())
|
||||
await asyncio.create_task(ctx.checkSlots())
|
||||
await asyncio.create_task(ctx.verifyChests())
|
||||
await asyncio.create_task(ctx.verifyItems())
|
||||
await asyncio.create_task(ctx.verifyLevel())
|
||||
message = [{"cmd": 'LocationChecks', "locations": ctx.sending}]
|
||||
if finishedGame(ctx, message):
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
location_ids = []
|
||||
location_ids = [location for location in message[0]["locations"] if location not in location_ids]
|
||||
for location in location_ids:
|
||||
if location not in ctx.locations_checked:
|
||||
ctx.locations_checked.add(location)
|
||||
ctx.kh2seedsave["LocationsChecked"].append(location)
|
||||
if location in ctx.kh2LocalItems:
|
||||
item = ctx.kh2slotdata["LocalItems"][str(location)]
|
||||
await asyncio.create_task(ctx.give_item(item, "LocalItems"))
|
||||
await ctx.send_msgs(message)
|
||||
elif not ctx.kh2connected and ctx.serverconneced:
|
||||
logger.info("Game is not open. Disconnecting from Server.")
|
||||
await ctx.disconnect()
|
||||
except Exception as e:
|
||||
logger.info("Line 661")
|
||||
if ctx.kh2connected:
|
||||
logger.info("Connection Lost.")
|
||||
ctx.kh2connected = False
|
||||
logger.info(e)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
async def main(args):
|
||||
ctx = KH2Context(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
progression_watcher = asyncio.create_task(
|
||||
kh2_watcher(ctx), name="KH2ProgressionWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await progression_watcher
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="KH2 Client, for text interfacing.")
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
247
Launcher.py
@@ -11,13 +11,19 @@ Scroll down to components= to add components to the launcher as well as setup.py
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import logging
|
||||
import multiprocessing
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from enum import Enum, auto
|
||||
import webbrowser
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
from typing import Iterable, Sequence, Callable, Union, Optional
|
||||
from typing import Sequence, Union, Optional
|
||||
|
||||
import Utils
|
||||
import settings
|
||||
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
|
||||
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
@@ -28,7 +34,8 @@ from Utils import is_frozen, user_path, local_path, init_logging, open_filename,
|
||||
|
||||
|
||||
def open_host_yaml():
|
||||
file = user_path('host.yaml')
|
||||
file = settings.get_settings().filename
|
||||
assert file, "host.yaml missing"
|
||||
if is_linux:
|
||||
exe = which('sensible-editor') or which('gedit') or \
|
||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
@@ -37,7 +44,6 @@ def open_host_yaml():
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, file])
|
||||
else:
|
||||
import webbrowser
|
||||
webbrowser.open(file)
|
||||
|
||||
|
||||
@@ -52,131 +58,59 @@ def open_patch():
|
||||
except Exception as e:
|
||||
messagebox('Error', str(e), error=True)
|
||||
else:
|
||||
file, _, component = identify(filename)
|
||||
file, component = identify(filename)
|
||||
if file and component:
|
||||
launch([*get_exe(component), file], component.cli)
|
||||
|
||||
|
||||
def generate_yamls():
|
||||
from Options import generate_yaml_templates
|
||||
|
||||
target = Utils.user_path("Players", "Templates")
|
||||
generate_yaml_templates(target, False)
|
||||
open_folder(target)
|
||||
|
||||
|
||||
def browse_files():
|
||||
file = user_path()
|
||||
open_folder(user_path())
|
||||
|
||||
|
||||
def open_folder(folder_path):
|
||||
if is_linux:
|
||||
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
subprocess.Popen([exe, file])
|
||||
subprocess.Popen([exe, folder_path])
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, file])
|
||||
subprocess.Popen([exe, folder_path])
|
||||
else:
|
||||
import webbrowser
|
||||
webbrowser.open(file)
|
||||
webbrowser.open(folder_path)
|
||||
|
||||
|
||||
# noinspection PyArgumentList
|
||||
class Type(Enum):
|
||||
TOOL = auto()
|
||||
FUNC = auto() # not a real component
|
||||
CLIENT = auto()
|
||||
ADJUSTER = auto()
|
||||
def update_settings():
|
||||
from settings import get_settings
|
||||
get_settings().save()
|
||||
|
||||
|
||||
class SuffixIdentifier:
|
||||
suffixes: Iterable[str]
|
||||
|
||||
def __init__(self, *args: str):
|
||||
self.suffixes = args
|
||||
|
||||
def __call__(self, path: str):
|
||||
if isinstance(path, str):
|
||||
for suffix in self.suffixes:
|
||||
if path.endswith(suffix):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Component:
|
||||
display_name: str
|
||||
type: Optional[Type]
|
||||
script_name: Optional[str]
|
||||
frozen_name: Optional[str]
|
||||
icon: str # just the name, no suffix
|
||||
cli: bool
|
||||
func: Optional[Callable]
|
||||
file_identifier: Optional[Callable[[str], bool]]
|
||||
|
||||
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
|
||||
cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None,
|
||||
file_identifier: Optional[Callable[[str], bool]] = None):
|
||||
self.display_name = display_name
|
||||
self.script_name = script_name
|
||||
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
|
||||
self.icon = icon
|
||||
self.cli = cli
|
||||
self.type = component_type or \
|
||||
None if not display_name else \
|
||||
Type.FUNC if func else \
|
||||
Type.CLIENT if 'Client' in display_name else \
|
||||
Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL
|
||||
self.func = func
|
||||
self.file_identifier = file_identifier
|
||||
|
||||
def handles_file(self, path: str):
|
||||
return self.file_identifier(path) if self.file_identifier else False
|
||||
|
||||
|
||||
components: Iterable[Component] = (
|
||||
# Launcher
|
||||
Component('', 'Launcher'),
|
||||
# Core
|
||||
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
|
||||
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
|
||||
Component('Generate', 'Generate', cli=True),
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
||||
# SNI
|
||||
Component('SNI Client', 'SNIClient',
|
||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3',
|
||||
'.apsmw', '.apl2ac')),
|
||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||
# Factorio
|
||||
Component('Factorio Client', 'FactorioClient'),
|
||||
# Minecraft
|
||||
Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True,
|
||||
file_identifier=SuffixIdentifier('.apmc')),
|
||||
# Ocarina of Time
|
||||
Component('OoT Client', 'OoTClient',
|
||||
file_identifier=SuffixIdentifier('.apz5')),
|
||||
Component('OoT Adjuster', 'OoTAdjuster'),
|
||||
# FF1
|
||||
Component('FF1 Client', 'FF1Client'),
|
||||
# Pokémon
|
||||
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
|
||||
# TLoZ
|
||||
Component('Zelda 1 Client', 'Zelda1Client'),
|
||||
# ChecksFinder
|
||||
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
||||
# Starcraft 2
|
||||
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
||||
# Wargroove
|
||||
Component('Wargroove Client', 'WargrooveClient'),
|
||||
# Zillion
|
||||
Component('Zillion Client', 'ZillionClient',
|
||||
file_identifier=SuffixIdentifier('.apzl')),
|
||||
components.extend([
|
||||
# Functions
|
||||
Component('Open host.yaml', func=open_host_yaml),
|
||||
Component('Open Patch', func=open_patch),
|
||||
Component('Browse Files', func=browse_files),
|
||||
)
|
||||
icon_paths = {
|
||||
'icon': local_path('data', 'icon.ico' if is_windows else 'icon.png'),
|
||||
'mcicon': local_path('data', 'mcicon.ico')
|
||||
}
|
||||
Component("Open host.yaml", func=open_host_yaml),
|
||||
Component("Open Patch", func=open_patch),
|
||||
Component("Generate Template Settings", func=generate_yamls),
|
||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
||||
Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
||||
Component("Browse Files", func=browse_files),
|
||||
])
|
||||
|
||||
|
||||
def identify(path: Union[None, str]):
|
||||
if path is None:
|
||||
return None, None, None
|
||||
return None, None
|
||||
for component in components:
|
||||
if component.handles_file(path):
|
||||
return path, component.script_name, component
|
||||
return (None, None, None) if '/' in path or '\\' in path else (None, path, None)
|
||||
return path, component
|
||||
elif path == component.display_name or path == component.script_name:
|
||||
return None, component
|
||||
return None, None
|
||||
|
||||
|
||||
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
||||
@@ -223,16 +157,18 @@ def launch(exe, in_terminal=False):
|
||||
|
||||
def run_gui():
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label
|
||||
from kivy.uix.image import AsyncImage
|
||||
from kivy.uix.relativelayout import RelativeLayout
|
||||
|
||||
class Launcher(App):
|
||||
base_title: str = "Archipelago Launcher"
|
||||
container: ContainerLayout
|
||||
grid: GridLayout
|
||||
|
||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL and isfile(get_exe(c)[-1])}
|
||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT and isfile(get_exe(c)[-1])}
|
||||
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER and isfile(get_exe(c)[-1])}
|
||||
_funcs = {c.display_name: c for c in components if c.type == Type.FUNC}
|
||||
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
|
||||
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
|
||||
_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}
|
||||
|
||||
def __init__(self, ctx=None):
|
||||
self.title = self.base_title
|
||||
@@ -244,24 +180,44 @@ def run_gui():
|
||||
self.container = ContainerLayout()
|
||||
self.grid = GridLayout(cols=2)
|
||||
self.container.add_widget(self.grid)
|
||||
|
||||
self.grid.add_widget(Label(text="General"))
|
||||
self.grid.add_widget(Label(text="Clients"))
|
||||
button_layout = self.grid # make buttons fill the window
|
||||
|
||||
def build_button(component: Component):
|
||||
"""
|
||||
Builds a button widget for a given component.
|
||||
|
||||
Args:
|
||||
component (Component): The component associated with the button.
|
||||
|
||||
Returns:
|
||||
None. The button is added to the parent grid layout.
|
||||
|
||||
"""
|
||||
button = Button(text=component.display_name)
|
||||
button.component = component
|
||||
button.bind(on_release=self.component_action)
|
||||
if component.icon != "icon":
|
||||
image = AsyncImage(source=icon_paths[component.icon],
|
||||
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
|
||||
box_layout = RelativeLayout()
|
||||
box_layout.add_widget(button)
|
||||
box_layout.add_widget(image)
|
||||
button_layout.add_widget(box_layout)
|
||||
else:
|
||||
button_layout.add_widget(button)
|
||||
|
||||
for (tool, client) in itertools.zip_longest(itertools.chain(
|
||||
self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()):
|
||||
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
|
||||
# column 1
|
||||
if tool:
|
||||
button = Button(text=tool[0])
|
||||
button.component = tool[1]
|
||||
button.bind(on_release=self.component_action)
|
||||
button_layout.add_widget(button)
|
||||
build_button(tool[1])
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
# column 2
|
||||
if client:
|
||||
button = Button(text=client[0])
|
||||
button.component = client[1]
|
||||
button.bind(on_press=self.component_action)
|
||||
button_layout.add_widget(button)
|
||||
build_button(client[1])
|
||||
else:
|
||||
button_layout.add_widget(Label())
|
||||
|
||||
@@ -269,14 +225,29 @@ def run_gui():
|
||||
|
||||
@staticmethod
|
||||
def component_action(button):
|
||||
if button.component.type == Type.FUNC:
|
||||
if button.component.func:
|
||||
button.component.func()
|
||||
else:
|
||||
launch(get_exe(button.component), button.component.cli)
|
||||
|
||||
def _stop(self, *largs):
|
||||
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
|
||||
# Closing the window explicitly cleans it up.
|
||||
self.root_window.close()
|
||||
super()._stop(*largs)
|
||||
|
||||
Launcher().run()
|
||||
|
||||
|
||||
def run_component(component: Component, *args):
|
||||
if component.func:
|
||||
component.func(*args)
|
||||
elif component.script_name:
|
||||
subprocess.run([*get_exe(component.script_name), *args])
|
||||
else:
|
||||
logging.warning(f"Component {component} does not appear to be executable.")
|
||||
|
||||
|
||||
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
if isinstance(args, argparse.Namespace):
|
||||
args = {k: v for k, v in args._get_kwargs()}
|
||||
@@ -284,24 +255,40 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
args = {}
|
||||
|
||||
if "Patch|Game|Component" in args:
|
||||
file, component, _ = identify(args["Patch|Game|Component"])
|
||||
file, component = identify(args["Patch|Game|Component"])
|
||||
if file:
|
||||
args['file'] = file
|
||||
if component:
|
||||
args['component'] = component
|
||||
if not component:
|
||||
logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
|
||||
|
||||
if args["update_settings"]:
|
||||
update_settings()
|
||||
if 'file' in args:
|
||||
subprocess.run([*get_exe(args['component']), args['file'], *args['args']])
|
||||
run_component(args["component"], args["file"], *args["args"])
|
||||
elif 'component' in args:
|
||||
subprocess.run([*get_exe(args['component']), *args['args']])
|
||||
else:
|
||||
run_component(args["component"], *args["args"])
|
||||
elif not args["update_settings"]:
|
||||
run_gui()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_logging('Launcher')
|
||||
Utils.freeze_support()
|
||||
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
|
||||
parser = argparse.ArgumentParser(description='Archipelago Launcher')
|
||||
parser.add_argument('Patch|Game|Component', type=str, nargs='?',
|
||||
help="Pass either a patch file, a generated game or the name of a component to run.")
|
||||
parser.add_argument('args', nargs="*", help="Arguments to pass to component.")
|
||||
run_group = parser.add_argument_group("Run")
|
||||
run_group.add_argument("--update_settings", action="store_true",
|
||||
help="Update host.yaml and exit.")
|
||||
run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
|
||||
help="Pass either a patch file, a generated game or the name of a component to run.")
|
||||
run_group.add_argument("args", nargs="*",
|
||||
help="Arguments to pass to component.")
|
||||
main(parser.parse_args())
|
||||
|
||||
from worlds.LauncherComponents import processes
|
||||
for process in processes:
|
||||
# we await all child processes to close before we tear down the process host
|
||||
# this makes it feel like each one is its own program, as the Launcher is closed now
|
||||
process.join()
|
||||
|
||||
700
LinksAwakeningClient.py
Normal file
@@ -0,0 +1,700 @@
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import colorama
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shlex
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
import typing
|
||||
|
||||
|
||||
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
||||
server_loop)
|
||||
from NetUtils import ClientStatus
|
||||
from worlds.ladx.Common import BASE_ID as LABaseID
|
||||
from worlds.ladx.GpsTracker import GpsTracker
|
||||
from worlds.ladx.ItemTracker import ItemTracker
|
||||
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
||||
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
||||
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
|
||||
|
||||
|
||||
class GameboyException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RetroArchDisconnectError(GameboyException):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidEmulatorStateError(GameboyException):
|
||||
pass
|
||||
|
||||
|
||||
class BadRetroArchResponse(GameboyException):
|
||||
pass
|
||||
|
||||
|
||||
def magpie_logo():
|
||||
from kivy.uix.image import CoreImage
|
||||
binary_data = """
|
||||
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
|
||||
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
|
||||
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
|
||||
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
|
||||
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
|
||||
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
|
||||
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
|
||||
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
|
||||
binary_data = base64.b64decode(binary_data)
|
||||
data = io.BytesIO(binary_data)
|
||||
return CoreImage(data, ext="png").texture
|
||||
|
||||
|
||||
class LAClientConstants:
|
||||
# Connector version
|
||||
VERSION = 0x01
|
||||
#
|
||||
# Memory locations of LADXR
|
||||
ROMGameID = 0x0051 # 4 bytes
|
||||
SlotName = 0x0134
|
||||
# Unused
|
||||
# ROMWorldID = 0x0055
|
||||
# ROMConnectorVersion = 0x0056
|
||||
# RO: We should only act if this is higher then 6, as it indicates that the game is running normally
|
||||
wGameplayType = 0xDB95
|
||||
# RO: Starts at 0, increases every time an item is received from the server and processed
|
||||
wLinkSyncSequenceNumber = 0xDDF6
|
||||
wLinkStatusBits = 0xDDF7 # RW:
|
||||
# Bit0: wLinkGive* contains valid data, set from script cleared from ROM.
|
||||
wLinkHealth = 0xDB5A
|
||||
wLinkGiveItem = 0xDDF8 # RW
|
||||
wLinkGiveItemFrom = 0xDDF9 # RW
|
||||
# All of these six bytes are unused, we can repurpose
|
||||
# wLinkSendItemRoomHigh = 0xDDFA # RO
|
||||
# wLinkSendItemRoomLow = 0xDDFB # RO
|
||||
# wLinkSendItemTarget = 0xDDFC # RO
|
||||
# wLinkSendItemItem = 0xDDFD # RO
|
||||
# wLinkSendShopItem = 0xDDFE # RO, which item to send (1 based, order of the shop items)
|
||||
# RO, which player to send to, but it's just the X position of the NPC used, so 0x18 is player 0
|
||||
# wLinkSendShopTarget = 0xDDFF
|
||||
|
||||
|
||||
wRecvIndex = 0xDDFD # Two bytes
|
||||
wCheckAddress = 0xC0FF - 0x4
|
||||
WRamCheckSize = 0x4
|
||||
WRamSafetyValue = bytearray([0]*WRamCheckSize)
|
||||
|
||||
MinGameplayValue = 0x06
|
||||
MaxGameplayValue = 0x1A
|
||||
VictoryGameplayAndSub = 0x0102
|
||||
|
||||
|
||||
class RAGameboy():
|
||||
cache = []
|
||||
cache_start = 0
|
||||
cache_size = 0
|
||||
last_cache_read = None
|
||||
socket = None
|
||||
|
||||
def __init__(self, address, port) -> None:
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
assert (self.socket)
|
||||
self.socket.setblocking(False)
|
||||
|
||||
async def send_command(self, command, timeout=1.0):
|
||||
self.send(f'{command}\n')
|
||||
response_str = await self.async_recv()
|
||||
self.check_command_response(command, response_str)
|
||||
return response_str.rstrip()
|
||||
|
||||
async def get_retroarch_version(self):
|
||||
return await self.send_command("VERSION")
|
||||
|
||||
async def get_retroarch_status(self):
|
||||
return await self.send_command("GET_STATUS")
|
||||
|
||||
def set_cache_limits(self, cache_start, cache_size):
|
||||
self.cache_start = cache_start
|
||||
self.cache_size = cache_size
|
||||
|
||||
def send(self, b):
|
||||
if type(b) is str:
|
||||
b = b.encode('ascii')
|
||||
self.socket.sendto(b, (self.address, self.port))
|
||||
|
||||
def recv(self):
|
||||
select.select([self.socket], [], [])
|
||||
response, _ = self.socket.recvfrom(4096)
|
||||
return response
|
||||
|
||||
async def async_recv(self, timeout=1.0):
|
||||
response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), timeout)
|
||||
return response
|
||||
|
||||
async def check_safe_gameplay(self, throw=True):
|
||||
async def check_wram():
|
||||
check_values = await self.async_read_memory(LAClientConstants.wCheckAddress, LAClientConstants.WRamCheckSize)
|
||||
|
||||
if check_values != LAClientConstants.WRamSafetyValue:
|
||||
if throw:
|
||||
raise InvalidEmulatorStateError()
|
||||
return False
|
||||
return True
|
||||
|
||||
if not await check_wram():
|
||||
if throw:
|
||||
raise InvalidEmulatorStateError()
|
||||
return False
|
||||
|
||||
gameplay_value = await self.async_read_memory(LAClientConstants.wGameplayType)
|
||||
gameplay_value = gameplay_value[0]
|
||||
# In gameplay or credits
|
||||
if not (LAClientConstants.MinGameplayValue <= gameplay_value <= LAClientConstants.MaxGameplayValue) and gameplay_value != 0x1:
|
||||
if throw:
|
||||
logger.info("invalid emu state")
|
||||
raise InvalidEmulatorStateError()
|
||||
return False
|
||||
if not await check_wram():
|
||||
if throw:
|
||||
raise InvalidEmulatorStateError()
|
||||
return False
|
||||
return True
|
||||
|
||||
# We're sadly unable to update the whole cache at once
|
||||
# as RetroArch only gives back some number of bytes at a time
|
||||
# So instead read as big as chunks at a time as we can manage
|
||||
async def update_cache(self):
|
||||
# First read the safety address - if it's invalid, bail
|
||||
self.cache = []
|
||||
|
||||
if not await self.check_safe_gameplay():
|
||||
return
|
||||
|
||||
cache = []
|
||||
remaining_size = self.cache_size
|
||||
while remaining_size:
|
||||
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
|
||||
remaining_size -= len(block)
|
||||
cache += block
|
||||
|
||||
if not await self.check_safe_gameplay():
|
||||
return
|
||||
|
||||
self.cache = cache
|
||||
self.last_cache_read = time.time()
|
||||
|
||||
async def read_memory_cache(self, addresses):
|
||||
# TODO: can we just update once per frame?
|
||||
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
|
||||
await self.update_cache()
|
||||
if not self.cache:
|
||||
return None
|
||||
assert (len(self.cache) == self.cache_size)
|
||||
for address in addresses:
|
||||
assert self.cache_start <= address <= self.cache_start + self.cache_size
|
||||
r = {address: self.cache[address - self.cache_start]
|
||||
for address in addresses}
|
||||
return r
|
||||
|
||||
async def async_read_memory_safe(self, address, size=1):
|
||||
# whenever we do a read for a check, we need to make sure that we aren't reading
|
||||
# garbage memory values - we also need to protect against reading a value, then the emulator resetting
|
||||
#
|
||||
# ...actually, we probably _only_ need the post check
|
||||
|
||||
# Check before read
|
||||
if not await self.check_safe_gameplay():
|
||||
return None
|
||||
|
||||
# Do read
|
||||
r = await self.async_read_memory(address, size)
|
||||
|
||||
# Check after read
|
||||
if not await self.check_safe_gameplay():
|
||||
return None
|
||||
|
||||
return r
|
||||
|
||||
def check_command_response(self, command: str, response: bytes):
|
||||
if command == "VERSION":
|
||||
ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None
|
||||
else:
|
||||
ok = response.startswith(command.encode())
|
||||
if not ok:
|
||||
logger.warning(f"Bad response to command {command} - {response}")
|
||||
raise BadRetroArchResponse()
|
||||
|
||||
def read_memory(self, address, size=1):
|
||||
command = "READ_CORE_MEMORY"
|
||||
|
||||
self.send(f'{command} {hex(address)} {size}\n')
|
||||
response = self.recv()
|
||||
|
||||
self.check_command_response(command, response)
|
||||
|
||||
splits = response.decode().split(" ", 2)
|
||||
# Ignore the address for now
|
||||
if splits[2][:2] == "-1":
|
||||
raise BadRetroArchResponse()
|
||||
|
||||
# TODO: check response address, check hex behavior between RA and BH
|
||||
|
||||
return bytearray.fromhex(splits[2])
|
||||
|
||||
async def async_read_memory(self, address, size=1):
|
||||
command = "READ_CORE_MEMORY"
|
||||
|
||||
self.send(f'{command} {hex(address)} {size}\n')
|
||||
response = await self.async_recv()
|
||||
self.check_command_response(command, response)
|
||||
response = response[:-1]
|
||||
splits = response.decode().split(" ", 2)
|
||||
try:
|
||||
response_addr = int(splits[1], 16)
|
||||
except ValueError:
|
||||
raise BadRetroArchResponse()
|
||||
|
||||
if response_addr != address:
|
||||
raise BadRetroArchResponse()
|
||||
|
||||
ret = bytearray.fromhex(splits[2])
|
||||
if len(ret) > size:
|
||||
raise BadRetroArchResponse()
|
||||
return ret
|
||||
|
||||
def write_memory(self, address, bytes):
|
||||
command = "WRITE_CORE_MEMORY"
|
||||
|
||||
self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}')
|
||||
select.select([self.socket], [], [])
|
||||
response, _ = self.socket.recvfrom(4096)
|
||||
self.check_command_response(command, response)
|
||||
splits = response.decode().split(" ", 3)
|
||||
|
||||
assert (splits[0] == command)
|
||||
|
||||
if splits[2] == "-1":
|
||||
logger.info(splits[3])
|
||||
|
||||
|
||||
class LinksAwakeningClient():
|
||||
socket = None
|
||||
gameboy = None
|
||||
tracker = None
|
||||
auth = None
|
||||
game_crc = None
|
||||
pending_deathlink = False
|
||||
deathlink_debounce = True
|
||||
recvd_checks = {}
|
||||
retroarch_address = None
|
||||
retroarch_port = None
|
||||
gameboy = None
|
||||
|
||||
def msg(self, m):
|
||||
logger.info(m)
|
||||
s = f"SHOW_MSG {m}\n"
|
||||
self.gameboy.send(s)
|
||||
|
||||
def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355):
|
||||
self.retroarch_address = retroarch_address
|
||||
self.retroarch_port = retroarch_port
|
||||
pass
|
||||
|
||||
stop_bizhawk_spam = False
|
||||
async def wait_for_retroarch_connection(self):
|
||||
if not self.stop_bizhawk_spam:
|
||||
logger.info("Waiting on connection to Retroarch...")
|
||||
self.stop_bizhawk_spam = True
|
||||
self.gameboy = RAGameboy(self.retroarch_address, self.retroarch_port)
|
||||
|
||||
while True:
|
||||
try:
|
||||
version = await self.gameboy.get_retroarch_version()
|
||||
NO_CONTENT = b"GET_STATUS CONTENTLESS"
|
||||
status = NO_CONTENT
|
||||
core_type = None
|
||||
GAME_BOY = b"game_boy"
|
||||
while status == NO_CONTENT or core_type != GAME_BOY:
|
||||
status = await self.gameboy.get_retroarch_status()
|
||||
if status.count(b" ") < 2:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
GET_STATUS, PLAYING, info = status.split(b" ", 2)
|
||||
if status.count(b",") < 2:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
core_type, rom_name, self.game_crc = info.split(b",", 2)
|
||||
if core_type != GAME_BOY:
|
||||
logger.info(
|
||||
f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?")
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
self.stop_bizhawk_spam = False
|
||||
logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}")
|
||||
return
|
||||
except (BlockingIOError, TimeoutError, ConnectionResetError):
|
||||
await asyncio.sleep(1.0)
|
||||
pass
|
||||
|
||||
async def reset_auth(self):
|
||||
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
|
||||
self.auth = auth
|
||||
|
||||
async def wait_and_init_tracker(self):
|
||||
await self.wait_for_game_ready()
|
||||
self.tracker = LocationTracker(self.gameboy)
|
||||
self.item_tracker = ItemTracker(self.gameboy)
|
||||
self.gps_tracker = GpsTracker(self.gameboy)
|
||||
|
||||
async def recved_item_from_ap(self, item_id, from_player, next_index):
|
||||
# Don't allow getting an item until you've got your first check
|
||||
if not self.tracker.has_start_item():
|
||||
return
|
||||
|
||||
# Spin until we either:
|
||||
# get an exception from a bad read (emu shut down or reset)
|
||||
# beat the game
|
||||
# the client handles the last pending item
|
||||
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
||||
while not (await self.is_victory()) and status & 1 == 1:
|
||||
time.sleep(0.1)
|
||||
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
|
||||
|
||||
item_id -= LABaseID
|
||||
# The player name table only goes up to 100, so don't go past that
|
||||
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
|
||||
if from_player > 100:
|
||||
from_player = 100
|
||||
|
||||
next_index += 1
|
||||
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
|
||||
item_id, from_player])
|
||||
status |= 1
|
||||
status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status])
|
||||
self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index))
|
||||
|
||||
should_reset_auth = False
|
||||
async def wait_for_game_ready(self):
|
||||
logger.info("Waiting on game to be in valid state...")
|
||||
while not await self.gameboy.check_safe_gameplay(throw=False):
|
||||
if self.should_reset_auth:
|
||||
self.should_reset_auth = False
|
||||
raise GameboyException("Resetting due to wrong archipelago server")
|
||||
logger.info("Game connection ready!")
|
||||
|
||||
async def is_victory(self):
|
||||
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
|
||||
|
||||
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
|
||||
await self.tracker.readChecks(item_get_cb)
|
||||
await self.item_tracker.readItems()
|
||||
await self.gps_tracker.read_location()
|
||||
|
||||
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
|
||||
if self.deathlink_debounce and current_health != 0:
|
||||
self.deathlink_debounce = False
|
||||
elif not self.deathlink_debounce and current_health == 0:
|
||||
# logger.info("YOU DIED.")
|
||||
await deathlink_cb()
|
||||
self.deathlink_debounce = True
|
||||
|
||||
if self.pending_deathlink:
|
||||
logger.info("Got a deathlink")
|
||||
self.gameboy.write_memory(LAClientConstants.wLinkHealth, [0])
|
||||
self.pending_deathlink = False
|
||||
self.deathlink_debounce = True
|
||||
|
||||
if await self.is_victory():
|
||||
await win_cb()
|
||||
|
||||
recv_index = struct.unpack(">H", await self.gameboy.async_read_memory(LAClientConstants.wRecvIndex, 2))[0]
|
||||
|
||||
# Play back one at a time
|
||||
if recv_index in self.recvd_checks:
|
||||
item = self.recvd_checks[recv_index]
|
||||
await self.recved_item_from_ap(item.item, item.player, recv_index)
|
||||
|
||||
|
||||
all_tasks = set()
|
||||
|
||||
def create_task_log_exception(awaitable) -> asyncio.Task:
|
||||
async def _log_exception(awaitable):
|
||||
try:
|
||||
return await awaitable
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
pass
|
||||
finally:
|
||||
all_tasks.remove(task)
|
||||
task = asyncio.create_task(_log_exception(awaitable))
|
||||
all_tasks.add(task)
|
||||
|
||||
|
||||
class LinksAwakeningContext(CommonContext):
|
||||
tags = {"AP"}
|
||||
game = "Links Awakening DX"
|
||||
items_handling = 0b101
|
||||
want_slot_data = True
|
||||
la_task = None
|
||||
client = None
|
||||
# TODO: does this need to re-read on reset?
|
||||
found_checks = []
|
||||
last_resend = time.time()
|
||||
|
||||
magpie_enabled = False
|
||||
magpie = None
|
||||
magpie_task = None
|
||||
won = False
|
||||
|
||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||
self.client = LinksAwakeningClient()
|
||||
if magpie:
|
||||
self.magpie_enabled = True
|
||||
self.magpie = MagpieBridge()
|
||||
super().__init__(server_address, password)
|
||||
|
||||
def run_gui(self) -> None:
|
||||
import webbrowser
|
||||
import kvui
|
||||
from kvui import Button, GameManager
|
||||
from kivy.uix.image import Image
|
||||
|
||||
class LADXManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("Tracker", "Tracker"),
|
||||
]
|
||||
base_title = "Archipelago Links Awakening DX Client"
|
||||
|
||||
def build(self):
|
||||
b = super().build()
|
||||
|
||||
if self.ctx.magpie_enabled:
|
||||
button = Button(text="", size=(30, 30), size_hint_x=None,
|
||||
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
|
||||
image = Image(size=(16, 16), texture=magpie_logo())
|
||||
button.add_widget(image)
|
||||
|
||||
def set_center(_, center):
|
||||
image.center = center
|
||||
button.bind(center=set_center)
|
||||
|
||||
self.connect_layout.add_widget(button)
|
||||
return b
|
||||
|
||||
self.ui = LADXManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
async def send_checks(self):
|
||||
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
|
||||
await self.send_msgs(message)
|
||||
|
||||
had_invalid_slot_data = None
|
||||
def event_invalid_slot(self):
|
||||
# The next time we try to connect, reset the game loop for new auth
|
||||
self.had_invalid_slot_data = True
|
||||
self.auth = None
|
||||
# Don't try to autoreconnect, it will just fail
|
||||
self.disconnected_intentionally = True
|
||||
CommonContext.event_invalid_slot(self)
|
||||
|
||||
ENABLE_DEATHLINK = False
|
||||
async def send_deathlink(self):
|
||||
if self.ENABLE_DEATHLINK:
|
||||
message = [{"cmd": 'Deathlink',
|
||||
'time': time.time(),
|
||||
'cause': 'Had a nightmare',
|
||||
# 'source': self.slot_info[self.slot].name,
|
||||
}]
|
||||
await self.send_msgs(message)
|
||||
|
||||
async def send_victory(self):
|
||||
if not self.won:
|
||||
message = [{"cmd": "StatusUpdate",
|
||||
"status": ClientStatus.CLIENT_GOAL}]
|
||||
logger.info("victory!")
|
||||
await self.send_msgs(message)
|
||||
self.won = True
|
||||
|
||||
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||
if self.ENABLE_DEATHLINK:
|
||||
self.client.pending_deathlink = True
|
||||
|
||||
def new_checks(self, item_ids, ladxr_ids):
|
||||
self.found_checks += item_ids
|
||||
create_task_log_exception(self.send_checks())
|
||||
if self.magpie_enabled:
|
||||
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(LinksAwakeningContext, self).server_auth(password_requested)
|
||||
|
||||
if self.had_invalid_slot_data:
|
||||
# We are connecting when previously we had the wrong ROM or server - just in case
|
||||
# re-read the ROM so that if the user had the correct address but wrong ROM, we
|
||||
# allow a successful reconnect
|
||||
self.client.should_reset_auth = True
|
||||
self.had_invalid_slot_data = False
|
||||
|
||||
while self.client.auth == None:
|
||||
await asyncio.sleep(0.1)
|
||||
self.auth = self.client.auth
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.slot_info[self.slot].game
|
||||
# TODO - use watcher_event
|
||||
if cmd == "ReceivedItems":
|
||||
for index, item in enumerate(args["items"], start=args["index"]):
|
||||
self.client.recvd_checks[index] = item
|
||||
|
||||
async def sync(self):
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
await self.send_msgs(sync_msg)
|
||||
|
||||
item_id_lookup = get_locations_to_id()
|
||||
|
||||
async def run_game_loop(self):
|
||||
def on_item_get(ladxr_checks):
|
||||
checks = [self.item_id_lookup[meta_to_name(
|
||||
checkMetadataTable[check.id])] for check in ladxr_checks]
|
||||
self.new_checks(checks, [check.id for check in ladxr_checks])
|
||||
|
||||
async def victory():
|
||||
await self.send_victory()
|
||||
|
||||
async def deathlink():
|
||||
await self.send_deathlink()
|
||||
|
||||
if self.magpie_enabled:
|
||||
self.magpie_task = asyncio.create_task(self.magpie.serve())
|
||||
|
||||
# yield to allow UI to start
|
||||
await asyncio.sleep(0)
|
||||
|
||||
while True:
|
||||
try:
|
||||
# TODO: cancel all client tasks
|
||||
if not self.client.stop_bizhawk_spam:
|
||||
logger.info("(Re)Starting game loop")
|
||||
self.found_checks.clear()
|
||||
# On restart of game loop, clear all checks, just in case we swapped ROMs
|
||||
# this isn't totally neccessary, but is extra safety against cross-ROM contamination
|
||||
self.client.recvd_checks.clear()
|
||||
await self.client.wait_for_retroarch_connection()
|
||||
await self.client.reset_auth()
|
||||
# If we find ourselves with new auth after the reset, reconnect
|
||||
if self.auth and self.client.auth != self.auth:
|
||||
# It would be neat to reconnect here, but connection needs this loop to be running
|
||||
logger.info("Detected new ROM, disconnecting...")
|
||||
await self.disconnect()
|
||||
continue
|
||||
|
||||
if not self.client.recvd_checks:
|
||||
await self.sync()
|
||||
|
||||
await self.client.wait_and_init_tracker()
|
||||
|
||||
while True:
|
||||
await self.client.main_tick(on_item_get, victory, deathlink)
|
||||
await asyncio.sleep(0.1)
|
||||
now = time.time()
|
||||
if self.last_resend + 5.0 < now:
|
||||
self.last_resend = now
|
||||
await self.send_checks()
|
||||
if self.magpie_enabled:
|
||||
try:
|
||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
||||
await self.magpie.send_gps(self.client.gps_tracker)
|
||||
except Exception:
|
||||
# Don't let magpie errors take out the client
|
||||
pass
|
||||
if self.client.should_reset_auth:
|
||||
self.client.should_reset_auth = False
|
||||
raise GameboyException("Resetting due to wrong archipelago server")
|
||||
except (GameboyException, asyncio.TimeoutError, TimeoutError, ConnectionResetError):
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
def run_game(romfile: str) -> None:
|
||||
auto_start = typing.cast(typing.Union[bool, str],
|
||||
Utils.get_options()["ladx_options"].get("rom_start", True))
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif isinstance(auto_start, str):
|
||||
args = shlex.split(auto_start)
|
||||
# Specify full path to ROM as we are going to cd in popen
|
||||
full_rom_path = os.path.realpath(romfile)
|
||||
args.append(full_rom_path)
|
||||
try:
|
||||
# set cwd so that paths to lua scripts are always relative to our client
|
||||
if getattr(sys, 'frozen', False):
|
||||
# The application is frozen
|
||||
script_dir = os.path.dirname(sys.executable)
|
||||
else:
|
||||
script_dir = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=script_dir)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Couldn't launch ROM, {args[0]} is missing")
|
||||
|
||||
async def main():
|
||||
parser = get_base_parser(description="Link's Awakening Client.")
|
||||
parser.add_argument("--url", help="Archipelago connection url")
|
||||
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
|
||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||
help='Path to a .apladx Archipelago Binary Patch file')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.diff_file:
|
||||
import Patch
|
||||
logger.info("patch file was supplied - creating rom...")
|
||||
meta, rom_file = Patch.create_rom_file(args.diff_file)
|
||||
if "server" in meta and not args.connect:
|
||||
args.connect = meta["server"]
|
||||
logger.info(f"wrote rom file to {rom_file}")
|
||||
|
||||
|
||||
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
|
||||
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
|
||||
# TODO: nothing about the lambda about has to be in a lambda
|
||||
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
# Down below run_gui so that we get errors out of the process
|
||||
if args.diff_file:
|
||||
run_game(rom_file)
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
await ctx.shutdown()
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
182
LttPAdjuster.py
@@ -25,7 +25,7 @@ ModuleUpdate.update()
|
||||
|
||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
||||
get_adjuster_settings, tkinter_center_window, init_logging
|
||||
get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging
|
||||
|
||||
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
@@ -43,8 +43,49 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
def _get_help_string(self, action):
|
||||
return textwrap.dedent(action.help)
|
||||
|
||||
# See argparse.BooleanOptionalAction
|
||||
class BooleanOptionalActionWithDisable(argparse.Action):
|
||||
def __init__(self,
|
||||
option_strings,
|
||||
dest,
|
||||
default=None,
|
||||
type=None,
|
||||
choices=None,
|
||||
required=False,
|
||||
help=None,
|
||||
metavar=None):
|
||||
|
||||
def main():
|
||||
_option_strings = []
|
||||
for option_string in option_strings:
|
||||
_option_strings.append(option_string)
|
||||
|
||||
if option_string.startswith('--'):
|
||||
option_string = '--disable' + option_string[2:]
|
||||
_option_strings.append(option_string)
|
||||
|
||||
if help is not None and default is not None:
|
||||
help += " (default: %(default)s)"
|
||||
|
||||
super().__init__(
|
||||
option_strings=_option_strings,
|
||||
dest=dest,
|
||||
nargs=0,
|
||||
default=default,
|
||||
type=type,
|
||||
choices=choices,
|
||||
required=required,
|
||||
help=help,
|
||||
metavar=metavar)
|
||||
|
||||
def __call__(self, parser, namespace, values, option_string=None):
|
||||
if option_string in self.option_strings:
|
||||
setattr(namespace, self.dest, not option_string.startswith('--disable'))
|
||||
|
||||
def format_usage(self):
|
||||
return ' | '.join(self.option_strings)
|
||||
|
||||
|
||||
def get_argparser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
|
||||
|
||||
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
|
||||
@@ -52,6 +93,8 @@ def main():
|
||||
help='Path to an ALttP Japan(1.0) rom to use as a base.')
|
||||
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
|
||||
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
|
||||
parser.add_argument('--auto_apply', default='ask',
|
||||
choices=['ask', 'always', 'never'], help='Whether or not to apply settings automatically in the future.')
|
||||
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
|
||||
choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'],
|
||||
help='''\
|
||||
@@ -61,7 +104,7 @@ def main():
|
||||
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
|
||||
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
|
||||
parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true')
|
||||
parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
|
||||
parser.add_argument('--music', default=True, help='Enables/Disables game music.', action=BooleanOptionalActionWithDisable)
|
||||
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
|
||||
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
|
||||
help='''\
|
||||
@@ -85,9 +128,6 @@ def main():
|
||||
parser.add_argument('--ow_palettes', default='default',
|
||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||
'sick'])
|
||||
# parser.add_argument('--link_palettes', default='default',
|
||||
# choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||
# 'sick'])
|
||||
parser.add_argument('--shield_palettes', default='default',
|
||||
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
|
||||
'sick'])
|
||||
@@ -107,10 +147,23 @@ def main():
|
||||
Alternatively, can be a ALttP Rom patched with a Link
|
||||
sprite that will be extracted.
|
||||
''')
|
||||
parser.add_argument('--names', default='', type=str)
|
||||
parser.add_argument('--sprite_pool', nargs='+', default=[], help='''
|
||||
A list of sprites to pull from.
|
||||
''')
|
||||
parser.add_argument('--oof', help='''\
|
||||
Path to a sound effect to replace Link's "oof" sound.
|
||||
Needs to be in a .brr format and have a length of no
|
||||
more than 2673 bytes, created from a 16-bit signed PCM
|
||||
.wav at 12khz. https://github.com/boldowa/snesbrr
|
||||
''')
|
||||
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
|
||||
args = parser.parse_args()
|
||||
args.music = not args.disablemusic
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = get_argparser()
|
||||
args = parser.parse_args(namespace=get_adjuster_settings_no_defaults(GAME_ALTTP))
|
||||
|
||||
# set up logger
|
||||
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
|
||||
args.loglevel]
|
||||
@@ -126,6 +179,13 @@ def main():
|
||||
if args.sprite is not None and not os.path.isfile(args.sprite) and not Sprite.get_sprite_from_name(args.sprite):
|
||||
input('Could not find link sprite sheet at given location. \nPress Enter to exit.')
|
||||
sys.exit(1)
|
||||
if args.oof is not None and not os.path.isfile(args.oof):
|
||||
input('Could not find oof sound effect at given location. \nPress Enter to exit.')
|
||||
sys.exit(1)
|
||||
if args.oof is not None and os.path.getsize(args.oof) > 2673:
|
||||
input('"oof" sound effect cannot exceed 2673 bytes. \nPress Enter to exit.')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
args, path = adjust(args=args)
|
||||
if isinstance(args.sprite, Sprite):
|
||||
@@ -165,7 +225,7 @@ def adjust(args):
|
||||
world = getattr(args, "world")
|
||||
|
||||
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
|
||||
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
|
||||
args.sprite, args.oof, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
|
||||
deathlink=args.deathlink, allowcollect=args.allowcollect)
|
||||
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
|
||||
rom.write_to_file(path)
|
||||
@@ -180,7 +240,7 @@ def adjustGUI():
|
||||
from tkinter import Tk, LEFT, BOTTOM, TOP, \
|
||||
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
|
||||
from argparse import Namespace
|
||||
from Main import __version__ as MWVersion
|
||||
from Utils import __version__ as MWVersion
|
||||
adjustWindow = Tk()
|
||||
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
|
||||
set_icon(adjustWindow)
|
||||
@@ -227,6 +287,7 @@ def adjustGUI():
|
||||
guiargs.sprite = rom_vars.sprite
|
||||
if rom_vars.sprite_pool:
|
||||
guiargs.world = AdjusterWorld(rom_vars.sprite_pool)
|
||||
guiargs.oof = rom_vars.oof
|
||||
|
||||
try:
|
||||
guiargs, path = adjust(args=guiargs)
|
||||
@@ -265,6 +326,7 @@ def adjustGUI():
|
||||
else:
|
||||
guiargs.sprite = rom_vars.sprite
|
||||
guiargs.sprite_pool = rom_vars.sprite_pool
|
||||
guiargs.oof = rom_vars.oof
|
||||
persistent_store("adjuster", GAME_ALTTP, guiargs)
|
||||
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
|
||||
|
||||
@@ -481,11 +543,38 @@ class BackgroundTaskProgressNullWindow(BackgroundTask):
|
||||
self.stop()
|
||||
|
||||
|
||||
class AttachTooltip(object):
|
||||
|
||||
def __init__(self, parent, text):
|
||||
self._parent = parent
|
||||
self._text = text
|
||||
self._window = None
|
||||
parent.bind('<Enter>', lambda event : self.show())
|
||||
parent.bind('<Leave>', lambda event : self.hide())
|
||||
|
||||
def show(self):
|
||||
if self._window or not self._text:
|
||||
return
|
||||
self._window = Toplevel(self._parent)
|
||||
#remove window bar controls
|
||||
self._window.wm_overrideredirect(1)
|
||||
#adjust positioning
|
||||
x, y, *_ = self._parent.bbox("insert")
|
||||
x = x + self._parent.winfo_rootx() + 20
|
||||
y = y + self._parent.winfo_rooty() + 20
|
||||
self._window.wm_geometry("+{0}+{1}".format(x,y))
|
||||
#show text
|
||||
label = Label(self._window, text=self._text, justify=LEFT)
|
||||
label.pack(ipadx=1)
|
||||
|
||||
def hide(self):
|
||||
if self._window:
|
||||
self._window.destroy()
|
||||
self._window = None
|
||||
|
||||
|
||||
def get_rom_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
if not adjuster_settings:
|
||||
adjuster_settings = Namespace()
|
||||
adjuster_settings.baserom = "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
|
||||
|
||||
romFrame = Frame(parent)
|
||||
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
||||
@@ -513,32 +602,8 @@ def get_rom_frame(parent=None):
|
||||
|
||||
return romFrame, romVar
|
||||
|
||||
|
||||
def get_rom_options_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
defaults = {
|
||||
"auto_apply": 'ask',
|
||||
"music": True,
|
||||
"reduceflashing": True,
|
||||
"deathlink": False,
|
||||
"sprite": None,
|
||||
"quickswap": True,
|
||||
"menuspeed": 'normal',
|
||||
"heartcolor": 'red',
|
||||
"heartbeep": 'normal',
|
||||
"ow_palettes": 'default',
|
||||
"uw_palettes": 'default',
|
||||
"hud_palettes": 'default',
|
||||
"sword_palettes": 'default',
|
||||
"shield_palettes": 'default',
|
||||
"sprite_pool": [],
|
||||
"allowcollect": False,
|
||||
}
|
||||
if not adjuster_settings:
|
||||
adjuster_settings = Namespace()
|
||||
for key, defaultvalue in defaults.items():
|
||||
if not hasattr(adjuster_settings, key):
|
||||
setattr(adjuster_settings, key, defaultvalue)
|
||||
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options")
|
||||
romOptionsFrame.columnconfigure(0, weight=1)
|
||||
@@ -598,12 +663,50 @@ def get_rom_options_frame(parent=None):
|
||||
spriteEntry.pack(side=LEFT)
|
||||
spriteSelectButton.pack(side=LEFT)
|
||||
|
||||
oofDialogFrame = Frame(romOptionsFrame)
|
||||
oofDialogFrame.grid(row=1, column=1)
|
||||
baseOofLabel = Label(oofDialogFrame, text='"OOF" Sound:')
|
||||
|
||||
vars.oofNameVar = StringVar()
|
||||
vars.oof = adjuster_settings.oof
|
||||
|
||||
def set_oof(oof_param):
|
||||
nonlocal vars
|
||||
if isinstance(oof_param, str) and os.path.isfile(oof_param) and os.path.getsize(oof_param) <= 2673:
|
||||
vars.oof = oof_param
|
||||
vars.oofNameVar.set(oof_param.rsplit('/',1)[-1])
|
||||
else:
|
||||
vars.oof = None
|
||||
vars.oofNameVar.set('(unchanged)')
|
||||
|
||||
set_oof(adjuster_settings.oof)
|
||||
oofEntry = Label(oofDialogFrame, textvariable=vars.oofNameVar)
|
||||
|
||||
def OofSelect():
|
||||
nonlocal vars
|
||||
oof_file = filedialog.askopenfilename(
|
||||
filetypes=[("BRR files", ".brr"),
|
||||
("All Files", "*")])
|
||||
try:
|
||||
set_oof(oof_file)
|
||||
except Exception:
|
||||
set_oof(None)
|
||||
|
||||
oofSelectButton = Button(oofDialogFrame, text='...', command=OofSelect)
|
||||
AttachTooltip(oofSelectButton,
|
||||
text="Select a .brr file no more than 2673 bytes.\n" + \
|
||||
"This can be created from a <=0.394s 16-bit signed PCM .wav file at 12khz using snesbrr.")
|
||||
|
||||
baseOofLabel.pack(side=LEFT)
|
||||
oofEntry.pack(side=LEFT)
|
||||
oofSelectButton.pack(side=LEFT)
|
||||
|
||||
vars.quickSwapVar = IntVar(value=adjuster_settings.quickswap)
|
||||
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
|
||||
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
|
||||
|
||||
menuspeedFrame = Frame(romOptionsFrame)
|
||||
menuspeedFrame.grid(row=1, column=1, sticky=E)
|
||||
menuspeedFrame.grid(row=6, column=1, sticky=E)
|
||||
menuspeedLabel = Label(menuspeedFrame, text='Menu speed')
|
||||
menuspeedLabel.pack(side=LEFT)
|
||||
vars.menuspeedVar = StringVar()
|
||||
@@ -1056,7 +1159,6 @@ class SpriteSelector():
|
||||
def custom_sprite_dir(self):
|
||||
return user_path("data", "sprites", "custom")
|
||||
|
||||
|
||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||
if not sprite.valid:
|
||||
return None
|
||||
|
||||
376
MMBN3Client.py
Normal file
@@ -0,0 +1,376 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
|
||||
import bsdiff4
|
||||
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
||||
ClientCommandProcessor, logger, get_base_parser
|
||||
import Utils
|
||||
from NetUtils import ClientStatus
|
||||
from worlds.mmbn3.Items import items_by_id
|
||||
from worlds.mmbn3.Rom import get_base_rom_path
|
||||
from worlds.mmbn3.Locations import all_locations, scoutable_locations
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_mmbn3.lua"
|
||||
CONNECTION_REFUSED_STATUS = \
|
||||
"Connection refused. Please start your emulator and make sure connector_mmbn3.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_mmbn3.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
CONNECTION_INCORRECT_ROM = "Supplied Base Rom does not match US GBA Blue Version. Please provide the correct ROM version"
|
||||
|
||||
script_version: int = 2
|
||||
|
||||
debugEnabled = False
|
||||
locations_checked = []
|
||||
items_sent = []
|
||||
itemIndex = 1
|
||||
|
||||
CHECKSUM_BLUE = "6fe31df0144759b34ad666badaacc442"
|
||||
|
||||
|
||||
class MMBN3CommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_gba(self):
|
||||
"""Check GBA Connection State"""
|
||||
if isinstance(self.ctx, MMBN3Context):
|
||||
logger.info(f"GBA Status: {self.ctx.gba_status}")
|
||||
|
||||
def _cmd_debug(self):
|
||||
"""Toggle the Debug Text overlay in ROM"""
|
||||
global debugEnabled
|
||||
debugEnabled = not debugEnabled
|
||||
logger.info("Debug Overlay Enabled" if debugEnabled else "Debug Overlay Disabled")
|
||||
|
||||
|
||||
class MMBN3Context(CommonContext):
|
||||
command_processor = MMBN3CommandProcessor
|
||||
game = "MegaMan Battle Network 3"
|
||||
items_handling = 0b001 # full local
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.gba_streams: (StreamReader, StreamWriter) = None
|
||||
self.gba_sync_task = None
|
||||
self.gba_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.location_table = {}
|
||||
self.version_warning = False
|
||||
self.auth_name = None
|
||||
self.slot_data = dict()
|
||||
self.patching_error = False
|
||||
self.sent_hints = []
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(MMBN3Context, self).server_auth(password_requested)
|
||||
|
||||
if self.auth_name is None:
|
||||
self.awaiting_rom = True
|
||||
logger.info("No ROM detected, awaiting conection to Bizhawk to authenticate to the multiworld server")
|
||||
return
|
||||
|
||||
logger.info("Attempting to decode from ROM... ")
|
||||
self.awaiting_rom = False
|
||||
self.auth = self.auth_name.decode("utf8").replace('\x00', '')
|
||||
logger.info("Connecting as "+self.auth)
|
||||
await self.send_connect(name=self.auth)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class MMBN3Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago MegaMan Battle Network 3 Client"
|
||||
|
||||
self.ui = MMBN3Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.slot_data = args.get("slot_data", {})
|
||||
print(self.slot_data)
|
||||
|
||||
class ItemInfo:
|
||||
id = 0x00
|
||||
sender = ""
|
||||
type = ""
|
||||
count = 1
|
||||
itemName = "Unknown"
|
||||
itemID = 0x00 # Item ID, Chip ID, etc.
|
||||
subItemID = 0x00 # Code for chips, color for programs
|
||||
itemIndex = 1
|
||||
|
||||
def __init__(self, id, sender, type):
|
||||
self.id = id
|
||||
self.sender = sender
|
||||
self.type = type
|
||||
|
||||
def get_json(self):
|
||||
json_data = {
|
||||
"id": self.id,
|
||||
"sender": self.sender,
|
||||
"type": self.type,
|
||||
"itemName": self.itemName,
|
||||
"itemID": self.itemID,
|
||||
"subItemID": self.subItemID,
|
||||
"count": self.count,
|
||||
"itemIndex": self.itemIndex
|
||||
}
|
||||
return json_data
|
||||
|
||||
|
||||
def get_payload(ctx: MMBN3Context):
|
||||
global debugEnabled
|
||||
|
||||
items_sent = []
|
||||
for i, item in enumerate(ctx.items_received):
|
||||
item_data = items_by_id[item.item]
|
||||
new_item = ItemInfo(i, ctx.player_names[item.player], item_data.type)
|
||||
new_item.itemIndex = i+1
|
||||
new_item.itemName = item_data.itemName
|
||||
new_item.type = item_data.type
|
||||
new_item.itemID = item_data.itemID
|
||||
new_item.subItemID = item_data.subItemID
|
||||
new_item.count = item_data.count
|
||||
items_sent.append(new_item)
|
||||
|
||||
return json.dumps({
|
||||
"items": [item.get_json() for item in items_sent],
|
||||
"debug": debugEnabled
|
||||
})
|
||||
|
||||
|
||||
async def parse_payload(payload: dict, ctx: MMBN3Context, force: bool):
|
||||
# Game completion handling
|
||||
if payload["gameComplete"] and not ctx.finished_game:
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "StatusUpdate",
|
||||
"status": ClientStatus.CLIENT_GOAL
|
||||
}])
|
||||
ctx.finished_game = True
|
||||
|
||||
# Locations handling
|
||||
if ctx.location_table != payload["locations"]:
|
||||
ctx.location_table = payload["locations"]
|
||||
locs = [loc.id for loc in all_locations
|
||||
if check_location_packet(loc, ctx.location_table)]
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationChecks",
|
||||
"locations": locs
|
||||
}])
|
||||
|
||||
# If trade hinting is enabled, send scout checks
|
||||
if ctx.slot_data.get("trade_quest_hinting", 0) == 2:
|
||||
trade_bits = [loc.id for loc in scoutable_locations
|
||||
if check_location_scouted(loc, payload["locations"])]
|
||||
scouted_locs = [loc for loc in trade_bits if loc not in ctx.sent_hints]
|
||||
if len(scouted_locs) > 0:
|
||||
ctx.sent_hints.extend(scouted_locs)
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationScouts",
|
||||
"locations": scouted_locs,
|
||||
"create_as_hint": 2
|
||||
}])
|
||||
|
||||
|
||||
def check_location_packet(location, memory):
|
||||
if len(memory) == 0:
|
||||
return False
|
||||
# Our keys have to be strings to come through the JSON lua plugin so we have to turn our memory address into a string as well
|
||||
location_key = hex(location.flag_byte)[2:]
|
||||
byte = memory.get(location_key)
|
||||
if byte is not None:
|
||||
return byte & location.flag_mask
|
||||
|
||||
|
||||
def check_location_scouted(location, memory):
|
||||
if len(memory) == 0:
|
||||
return False
|
||||
location_key = hex(location.hint_flag)[2:]
|
||||
byte = memory.get(location_key)
|
||||
if byte is not None:
|
||||
return byte & location.hint_flag_mask
|
||||
|
||||
|
||||
async def gba_sync_task(ctx: MMBN3Context):
|
||||
logger.info("Starting GBA connector. Use /gba for status information.")
|
||||
if ctx.patching_error:
|
||||
logger.error('Unable to Patch ROM. No ROM provided or ROM does not match US GBA Blue Version.')
|
||||
while not ctx.exit_event.is_set():
|
||||
error_status = None
|
||||
if ctx.gba_streams:
|
||||
(reader, writer) = ctx.gba_streams
|
||||
msg = get_payload(ctx).encode()
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to four fields
|
||||
# 1. str: player name (always)
|
||||
# 2. int: script version (always)
|
||||
# 3. dict[str, byte]: value of location's memory byte
|
||||
# 4. bool: whether the game currently registers as complete
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||
data_decoded = json.loads(data.decode())
|
||||
reported_version = data_decoded.get("scriptVersion", 0)
|
||||
if reported_version >= script_version:
|
||||
if ctx.game is not None and "locations" in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task((parse_payload(data_decoded, ctx, False)))
|
||||
if not ctx.auth:
|
||||
ctx.auth_name = bytes(data_decoded["playerName"])
|
||||
|
||||
if ctx.awaiting_rom:
|
||||
logger.info("Awaiting data from ROM...")
|
||||
await ctx.server_auth(False)
|
||||
else:
|
||||
if not ctx.version_warning:
|
||||
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}."
|
||||
"Please update to the latest version."
|
||||
"Your connection to the Archipelago server will not be accepted.")
|
||||
ctx.version_warning = True
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.gba_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.gba_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.gba_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.gba_streams = None
|
||||
if ctx.gba_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to GBA")
|
||||
ctx.gba_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.gba_status = f"Was tentatively connected but error occurred: {error_status}"
|
||||
elif error_status:
|
||||
ctx.gba_status = error_status
|
||||
logger.info("Lost connection to GBA and attempting to reconnect. Use /gba for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to GBA")
|
||||
ctx.gba_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28922), timeout=10)
|
||||
ctx.gba_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.gba_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
options = Utils.get_options().get("mmbn3_options", None)
|
||||
if options is None:
|
||||
auto_start = True
|
||||
else:
|
||||
auto_start = options.get("rom_start", True)
|
||||
if auto_start:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif os.path.isfile(auto_start):
|
||||
subprocess.Popen([auto_start, romfile],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def patch_and_run_game(apmmbn3_file):
|
||||
base_name = os.path.splitext(apmmbn3_file)[0]
|
||||
|
||||
with zipfile.ZipFile(apmmbn3_file, 'r') as patch_archive:
|
||||
try:
|
||||
with patch_archive.open("delta.bsdiff4", 'r') as stream:
|
||||
patch_data = stream.read()
|
||||
except KeyError:
|
||||
raise FileNotFoundError("Patch file missing from archive.")
|
||||
rom_file = get_base_rom_path()
|
||||
|
||||
with open(rom_file, 'rb') as rom:
|
||||
rom_bytes = rom.read()
|
||||
|
||||
patched_bytes = bsdiff4.patch(rom_bytes, patch_data)
|
||||
patched_rom_file = base_name+".gba"
|
||||
with open(patched_rom_file, 'wb') as patched_rom:
|
||||
patched_rom.write(patched_bytes)
|
||||
|
||||
asyncio.create_task(run_game(patched_rom_file))
|
||||
|
||||
|
||||
def confirm_checksum():
|
||||
rom_file = get_base_rom_path()
|
||||
if not os.path.exists(rom_file):
|
||||
return False
|
||||
|
||||
with open(rom_file, 'rb') as rom:
|
||||
rom_bytes = rom.read()
|
||||
|
||||
basemd5 = hashlib.md5()
|
||||
basemd5.update(rom_bytes)
|
||||
return CHECKSUM_BLUE == basemd5.hexdigest()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("MMBN3Client")
|
||||
|
||||
async def main():
|
||||
multiprocessing.freeze_support()
|
||||
parser = get_base_parser()
|
||||
parser.add_argument("patch_file", default="", type=str, nargs="?",
|
||||
help="Path to an APMMBN3 file")
|
||||
args = parser.parse_args()
|
||||
checksum_matches = confirm_checksum()
|
||||
if checksum_matches:
|
||||
if args.patch_file:
|
||||
asyncio.create_task(patch_and_run_game(args.patch_file))
|
||||
|
||||
ctx = MMBN3Context(args.connect, args.password)
|
||||
if not checksum_matches:
|
||||
ctx.patching_error = True
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
ctx.gba_sync_task = asyncio.create_task(gba_sync_task(ctx), name="GBA Sync")
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.gba_sync_task:
|
||||
await ctx.gba_sync_task
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
143
Main.py
@@ -1,34 +1,30 @@
|
||||
import collections
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import zlib
|
||||
import concurrent.futures
|
||||
import pickle
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from typing import Dict, List, Tuple, Optional, Set
|
||||
import zlib
|
||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
from BaseClasses import Item, MultiWorld, CollectionState, Region, LocationProgressType, Location
|
||||
import worlds
|
||||
from worlds.alttp.SubClasses import LTTPRegionType
|
||||
from worlds.alttp.Regions import is_main_entrance
|
||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||
from worlds.alttp.Shops import FillDisabledShopSlots
|
||||
from Utils import output_path, get_options, __version__, version_tuple
|
||||
from worlds.generic.Rules import locality_rules, exclusion_rules
|
||||
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
|
||||
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
|
||||
from Options import StartInventoryPool
|
||||
from settings import get_settings
|
||||
from Utils import __version__, output_path, version_tuple
|
||||
from worlds import AutoWorld
|
||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||
|
||||
ordered_areas = (
|
||||
'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
|
||||
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
|
||||
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total"
|
||||
)
|
||||
__all__ = ["main"]
|
||||
|
||||
|
||||
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
|
||||
if not baked_server_options:
|
||||
baked_server_options = get_options()["server_options"]
|
||||
baked_server_options = get_settings().server_options.as_dict()
|
||||
assert isinstance(baked_server_options, dict)
|
||||
if args.outputpath:
|
||||
os.makedirs(args.outputpath, exist_ok=True)
|
||||
output_path.cached_path = args.outputpath
|
||||
@@ -116,6 +112,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
for _ in range(count):
|
||||
world.push_precollected(world.create_item(item_name, player))
|
||||
|
||||
for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
|
||||
for _ in range(count):
|
||||
world.push_precollected(world.create_item(item_name, player))
|
||||
|
||||
logger.info('Creating World.')
|
||||
AutoWorld.call_all(world, "create_regions")
|
||||
|
||||
@@ -133,22 +133,62 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
world.non_local_items[player].value -= world.local_items[player].value
|
||||
world.non_local_items[player].value -= set(world.local_early_items[player])
|
||||
|
||||
if world.players > 1:
|
||||
locality_rules(world)
|
||||
else:
|
||||
world.non_local_items[1].value = set()
|
||||
world.local_items[1].value = set()
|
||||
|
||||
AutoWorld.call_all(world, "set_rules")
|
||||
|
||||
for player in world.player_ids:
|
||||
exclusion_rules(world, player, world.exclude_locations[player].value)
|
||||
world.priority_locations[player].value -= world.exclude_locations[player].value
|
||||
for location_name in world.priority_locations[player].value:
|
||||
world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY
|
||||
try:
|
||||
location = world.get_location(location_name, player)
|
||||
except KeyError as e: # failed to find the given location. Check if it's a legitimate location
|
||||
if location_name not in world.worlds[player].location_name_to_id:
|
||||
raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
|
||||
else:
|
||||
location.progress_type = LocationProgressType.PRIORITY
|
||||
|
||||
# Set local and non-local item rules.
|
||||
if world.players > 1:
|
||||
locality_rules(world)
|
||||
else:
|
||||
world.non_local_items[1].value = set()
|
||||
world.local_items[1].value = set()
|
||||
|
||||
AutoWorld.call_all(world, "generate_basic")
|
||||
|
||||
# remove starting inventory from pool items.
|
||||
# Because some worlds don't actually create items during create_items this has to be as late as possible.
|
||||
if any(world.start_inventory_from_pool[player].value for player in world.player_ids):
|
||||
new_items: List[Item] = []
|
||||
depletion_pool: Dict[int, Dict[str, int]] = {
|
||||
player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids}
|
||||
for player, items in depletion_pool.items():
|
||||
player_world: AutoWorld.World = world.worlds[player]
|
||||
for count in items.values():
|
||||
for _ in range(count):
|
||||
new_items.append(player_world.create_filler())
|
||||
target: int = sum(sum(items.values()) for items in depletion_pool.values())
|
||||
for i, item in enumerate(world.itempool):
|
||||
if depletion_pool[item.player].get(item.name, 0):
|
||||
target -= 1
|
||||
depletion_pool[item.player][item.name] -= 1
|
||||
# quick abort if we have found all items
|
||||
if not target:
|
||||
new_items.extend(world.itempool[i+1:])
|
||||
break
|
||||
else:
|
||||
new_items.append(item)
|
||||
|
||||
# leftovers?
|
||||
if target:
|
||||
for player, remaining_items in depletion_pool.items():
|
||||
remaining_items = {name: count for name, count in remaining_items.items() if count}
|
||||
if remaining_items:
|
||||
raise Exception(f"{world.get_player_name(player)}"
|
||||
f" is trying to remove items from their pool that don't exist: {remaining_items}")
|
||||
assert len(world.itempool) == len(new_items), "Item Pool amounts should not change."
|
||||
world.itempool[:] = new_items
|
||||
|
||||
# temporary home for item links, should be moved out of Main
|
||||
for group_id, group in world.groups.items():
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
|
||||
@@ -247,8 +287,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
AutoWorld.call_all(world, 'post_fill')
|
||||
|
||||
if world.players > 1:
|
||||
if world.players > 1 and not args.skip_prog_balancing:
|
||||
balance_multiworld_progression(world)
|
||||
else:
|
||||
logger.info("Progression balancing skipped.")
|
||||
|
||||
logger.info(f'Beginning output...')
|
||||
|
||||
@@ -273,35 +315,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
er_hint_data: Dict[int, Dict[int, str]] = {}
|
||||
AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
|
||||
|
||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||
for player in range(1, world.players + 1)}
|
||||
|
||||
for player in range(1, world.players + 1):
|
||||
checks_in_area[player]["Total"] = 0
|
||||
|
||||
for location in world.get_filled_locations():
|
||||
if type(location.address) is int:
|
||||
if location.game != "A Link to the Past":
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
else:
|
||||
main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance)
|
||||
if location.parent_region.dungeon:
|
||||
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
||||
'Inverted Ganons Tower': 'Ganons Tower'} \
|
||||
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||
checks_in_area[location.player][dungeonname].append(location.address)
|
||||
elif location.parent_region.type == LTTPRegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif location.parent_region.type == LTTPRegionType.DarkWorld:
|
||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||
elif main_entrance.parent_region.type == LTTPRegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif main_entrance.parent_region.type == LTTPRegionType.DarkWorld:
|
||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||
checks_in_area[location.player]["Total"] += 1
|
||||
|
||||
FillDisabledShopSlots(world)
|
||||
|
||||
def write_multidata():
|
||||
import NetUtils
|
||||
slot_data = {}
|
||||
@@ -316,7 +329,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
client_versions[slot] = player_world.required_client_version
|
||||
games[slot] = world.game[slot]
|
||||
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
|
||||
world.player_types[slot], bool(world.allow_collect[slot].value))
|
||||
world.player_types[slot])
|
||||
for slot, group in world.groups.items():
|
||||
games[slot] = world.game[slot]
|
||||
slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot],
|
||||
@@ -355,18 +368,17 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
for player in world.groups.get(location.item.player, {}).get("players", [])]):
|
||||
precollect_hint(location)
|
||||
|
||||
# custom datapackage
|
||||
datapackage = {}
|
||||
for game_world in world.worlds.values():
|
||||
if game_world.data_version == 0 and game_world.game not in datapackage:
|
||||
datapackage[game_world.game] = worlds.network_data_package["games"][game_world.game]
|
||||
datapackage[game_world.game]["item_name_groups"] = game_world.item_name_groups
|
||||
datapackage[game_world.game]["location_name_groups"] = game_world.location_name_groups
|
||||
# embedded data package
|
||||
data_package = {
|
||||
game_world.game: worlds.network_data_package["games"][game_world.game]
|
||||
for game_world in world.worlds.values()
|
||||
}
|
||||
|
||||
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
|
||||
|
||||
multidata = {
|
||||
"slot_data": slot_data,
|
||||
"slot_info": slot_info,
|
||||
"names": names, # TODO: remove after 0.3.9
|
||||
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
|
||||
"locations": locations_data,
|
||||
"checks_in_area": checks_in_area,
|
||||
@@ -378,7 +390,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
"tags": ["AP"],
|
||||
"minimum_versions": minimum_versions,
|
||||
"seed_name": world.seed_name,
|
||||
"datapackage": datapackage,
|
||||
"datapackage": data_package,
|
||||
}
|
||||
AutoWorld.call_all(world, "modify_multidata", multidata)
|
||||
|
||||
@@ -388,7 +400,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
f.write(bytes([3])) # version of format
|
||||
f.write(multidata)
|
||||
|
||||
multidata_task = pool.submit(write_multidata)
|
||||
output_file_futures.append(pool.submit(write_multidata))
|
||||
if not check_accessibility_task.result():
|
||||
if not world.can_beat_game():
|
||||
raise Exception("Game appears as unbeatable. Aborting.")
|
||||
@@ -396,7 +408,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||
|
||||
# retrieve exceptions via .result() if they occurred.
|
||||
multidata_task.result()
|
||||
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
|
||||
if i % 10 == 0 or i == len(output_file_futures):
|
||||
logger.info(f'Generating output files ({i}/{len(output_file_futures)}).')
|
||||
|
||||
@@ -77,49 +77,34 @@ def read_apmc_file(apmc_file):
|
||||
return json.loads(b64decode(f.read()))
|
||||
|
||||
|
||||
def update_mod(forge_dir, minecraft_version: str, get_prereleases=False):
|
||||
def update_mod(forge_dir, url: str):
|
||||
"""Check mod version, download new mod from GitHub releases page if needed. """
|
||||
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
||||
|
||||
client_releases_endpoint = "https://api.github.com/repos/KonoTyran/Minecraft_AP_Randomizer/releases"
|
||||
resp = requests.get(client_releases_endpoint)
|
||||
if resp.status_code == 200: # OK
|
||||
try:
|
||||
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
|
||||
(minecraft_version in release['assets'][0]['name']),
|
||||
resp.json()))
|
||||
if ap_randomizer != latest_release['assets'][0]['name']:
|
||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
||||
f"{latest_release['assets'][0]['name']}")
|
||||
if ap_randomizer is not None:
|
||||
logging.info(f"Your current mod is {ap_randomizer}.")
|
||||
else:
|
||||
logging.info(f"You do not have the AP randomizer mod installed.")
|
||||
if prompt_yes_no("Would you like to update?"):
|
||||
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
|
||||
new_ap_mod = os.path.join(forge_dir, 'mods', latest_release['assets'][0]['name'])
|
||||
logging.info("Downloading AP randomizer mod. This may take a moment...")
|
||||
apmod_resp = requests.get(latest_release['assets'][0]['browser_download_url'])
|
||||
if apmod_resp.status_code == 200:
|
||||
with open(new_ap_mod, 'wb') as f:
|
||||
f.write(apmod_resp.content)
|
||||
logging.info(f"Wrote new mod file to {new_ap_mod}")
|
||||
if old_ap_mod is not None:
|
||||
os.remove(old_ap_mod)
|
||||
logging.info(f"Removed old mod file from {old_ap_mod}")
|
||||
else:
|
||||
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
||||
logging.error(f"Please report this issue on the Archipelago Discord server.")
|
||||
sys.exit(1)
|
||||
except StopIteration:
|
||||
logging.warning(f"No compatible mod version found for {minecraft_version}.")
|
||||
if not prompt_yes_no("Run server anyway?"):
|
||||
sys.exit(0)
|
||||
os.path.basename(url)
|
||||
if ap_randomizer is not None:
|
||||
logging.info(f"Your current mod is {ap_randomizer}.")
|
||||
else:
|
||||
logging.error(f"Error checking for randomizer mod updates (status code {resp.status_code}).")
|
||||
logging.error(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)
|
||||
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):
|
||||
@@ -264,8 +249,13 @@ def get_minecraft_versions(version, release_channel="release"):
|
||||
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
||||
else:
|
||||
return resp.json()[release_channel][0]
|
||||
except StopIteration:
|
||||
logging.error(f"No compatible mod version found for client version {version}.")
|
||||
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:
|
||||
@@ -286,6 +276,8 @@ if __name__ == '__main__':
|
||||
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
|
||||
@@ -296,21 +288,22 @@ if __name__ == '__main__':
|
||||
options = Utils.get_options()
|
||||
channel = args.channel or options["minecraft_options"]["release_channel"]
|
||||
apmc_data = None
|
||||
data_version = 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:
|
||||
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 = Utils.user_path(options["minecraft_options"]["forge_directory"])
|
||||
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:
|
||||
@@ -344,7 +337,7 @@ if __name__ == '__main__':
|
||||
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, f"MC{forge_version.split('-')[0]}", channel != "release")
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import pkg_resources
|
||||
import multiprocessing
|
||||
import warnings
|
||||
|
||||
local_dir = os.path.dirname(__file__)
|
||||
@@ -10,7 +10,8 @@ requirements_files = {os.path.join(local_dir, 'requirements.txt')}
|
||||
if sys.version_info < (3, 8, 6):
|
||||
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
||||
|
||||
update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled
|
||||
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
|
||||
update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process()
|
||||
|
||||
if not update_ran:
|
||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||
@@ -22,18 +23,50 @@ if not update_ran:
|
||||
requirements_files.add(req_file)
|
||||
|
||||
|
||||
def check_pip():
|
||||
# detect if pip is available
|
||||
try:
|
||||
import pip # noqa: F401
|
||||
except ImportError:
|
||||
raise RuntimeError("pip not available. Please install pip.")
|
||||
|
||||
|
||||
def confirm(msg: str):
|
||||
try:
|
||||
input(f"\n{msg}")
|
||||
except KeyboardInterrupt:
|
||||
print("\nAborting")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def update_command():
|
||||
check_pip()
|
||||
for file in requirements_files:
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade'])
|
||||
subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--upgrade"])
|
||||
|
||||
|
||||
def install_pkg_resources(yes=False):
|
||||
try:
|
||||
import pkg_resources # noqa: F401
|
||||
except ImportError:
|
||||
check_pip()
|
||||
if not yes:
|
||||
confirm("pkg_resources not found, press enter to install it")
|
||||
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
|
||||
|
||||
|
||||
def update(yes=False, force=False):
|
||||
global update_ran
|
||||
if not update_ran:
|
||||
update_ran = True
|
||||
|
||||
if force:
|
||||
update_command()
|
||||
return
|
||||
|
||||
install_pkg_resources(yes=yes)
|
||||
import pkg_resources
|
||||
|
||||
for req_file in requirements_files:
|
||||
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
||||
if not os.path.exists(path):
|
||||
@@ -52,7 +85,7 @@ def update(yes=False, force=False):
|
||||
egg = egg.split(";", 1)[0].rstrip()
|
||||
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
|
||||
warnings.warn(f"Specifying version as #egg={egg} will become unavailable in pip 25.0. "
|
||||
"Use name @ url#version instead.", DeprecationWarning)
|
||||
"Use name @ url#version instead.", DeprecationWarning)
|
||||
line = egg
|
||||
else:
|
||||
egg = ""
|
||||
@@ -79,11 +112,7 @@ def update(yes=False, force=False):
|
||||
if not yes:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
try:
|
||||
input(f"\nRequirement {requirement} is not satisfied, press enter to install it")
|
||||
except KeyboardInterrupt:
|
||||
print("\nAborting")
|
||||
sys.exit(1)
|
||||
confirm(f"Requirement {requirement} is not satisfied, press enter to install it")
|
||||
update_command()
|
||||
return
|
||||
|
||||
|
||||
204
MultiServer.py
@@ -3,21 +3,21 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import asyncio
|
||||
import copy
|
||||
import functools
|
||||
import logging
|
||||
import zlib
|
||||
import collections
|
||||
import typing
|
||||
import inspect
|
||||
import weakref
|
||||
import datetime
|
||||
import threading
|
||||
import random
|
||||
import pickle
|
||||
import itertools
|
||||
import time
|
||||
import operator
|
||||
import functools
|
||||
import hashlib
|
||||
import inspect
|
||||
import itertools
|
||||
import logging
|
||||
import operator
|
||||
import pickle
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
import weakref
|
||||
import zlib
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
@@ -38,7 +38,7 @@ import NetUtils
|
||||
import Utils
|
||||
from Utils import version_tuple, restricted_loads, Version, async_start
|
||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||
SlotType
|
||||
SlotType, LocationStore
|
||||
|
||||
min_client_version = Version(0, 1, 6)
|
||||
colorama.init()
|
||||
@@ -152,14 +152,17 @@ class Context:
|
||||
"compatibility": int}
|
||||
# team -> slot id -> list of clients authenticated to slot.
|
||||
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
||||
locations: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
||||
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
||||
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
||||
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
||||
groups: typing.Dict[int, typing.Set[int]]
|
||||
save_version = 2
|
||||
stored_data: typing.Dict[str, object]
|
||||
read_data: typing.Dict[str, object]
|
||||
stored_data_notification_clients: typing.Dict[str, typing.Set[Client]]
|
||||
slot_info: typing.Dict[int, NetworkSlot]
|
||||
|
||||
generator_version = Version(0, 0, 0)
|
||||
checksums: typing.Dict[str, str]
|
||||
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||
@@ -186,8 +189,6 @@ class Context:
|
||||
self.player_name_lookup: typing.Dict[str, team_slot] = {}
|
||||
self.connect_names = {} # names of slots clients can connect to
|
||||
self.allow_releases = {}
|
||||
# player location_id item_id target_player_id
|
||||
self.locations = {}
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.server_password = server_password
|
||||
@@ -222,8 +223,7 @@ class Context:
|
||||
self.save_dirty = False
|
||||
self.tags = ['AP']
|
||||
self.games: typing.Dict[int, str] = {}
|
||||
self.allow_collect: typing.Dict[int, bool] = {}
|
||||
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
|
||||
self.minimum_client_versions: typing.Dict[int, Version] = {}
|
||||
self.seed_name = ""
|
||||
self.groups = {}
|
||||
self.group_collected: typing.Dict[int, typing.Set[int]] = {}
|
||||
@@ -234,6 +234,7 @@ class Context:
|
||||
|
||||
# init empty to satisfy linter, I suppose
|
||||
self.gamespackage = {}
|
||||
self.checksums = {}
|
||||
self.item_name_groups = {}
|
||||
self.location_name_groups = {}
|
||||
self.all_item_and_group_names = {}
|
||||
@@ -242,7 +243,7 @@ class Context:
|
||||
|
||||
self._load_game_data()
|
||||
|
||||
# Datapackage retrieval
|
||||
# Data package retrieval
|
||||
def _load_game_data(self):
|
||||
import worlds
|
||||
self.gamespackage = worlds.network_data_package["games"]
|
||||
@@ -256,6 +257,8 @@ class Context:
|
||||
|
||||
def _init_game_data(self):
|
||||
for game_name, game_package in self.gamespackage.items():
|
||||
if "checksum" in game_package:
|
||||
self.checksums[game_name] = game_package["checksum"]
|
||||
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||
self.item_names[item_id] = item_name
|
||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||
@@ -263,7 +266,7 @@ class Context:
|
||||
self.all_item_and_group_names[game_name] = \
|
||||
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
||||
self.all_location_and_group_names[game_name] = \
|
||||
set(game_package["location_name_to_id"]) | set(self.location_name_groups[game_name])
|
||||
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
|
||||
|
||||
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
||||
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
|
||||
@@ -281,6 +284,7 @@ class Context:
|
||||
except websockets.ConnectionClosed:
|
||||
logging.exception(f"Exception during send_msgs, could not send {msg}")
|
||||
await self.disconnect(endpoint)
|
||||
return False
|
||||
else:
|
||||
if self.log_network:
|
||||
logging.info(f"Outgoing message: {msg}")
|
||||
@@ -294,6 +298,7 @@ class Context:
|
||||
except websockets.ConnectionClosed:
|
||||
logging.exception("Exception during send_encoded_msgs")
|
||||
await self.disconnect(endpoint)
|
||||
return False
|
||||
else:
|
||||
if self.log_network:
|
||||
logging.info(f"Outgoing message: {msg}")
|
||||
@@ -308,6 +313,7 @@ class Context:
|
||||
websockets.broadcast(sockets, msg)
|
||||
except RuntimeError:
|
||||
logging.exception("Exception during broadcast_send_encoded_msgs")
|
||||
return False
|
||||
else:
|
||||
if self.log_network:
|
||||
logging.info(f"Outgoing broadcast: {msg}")
|
||||
@@ -352,7 +358,6 @@ class Context:
|
||||
for text in texts]))
|
||||
|
||||
# loading
|
||||
|
||||
def load(self, multidatapath: str, use_embedded_server_options: bool = False):
|
||||
if multidatapath.lower().endswith(".zip"):
|
||||
import zipfile
|
||||
@@ -367,7 +372,7 @@ class Context:
|
||||
with open(multidatapath, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
self._load(self.decompress(data), use_embedded_server_options)
|
||||
self._load(self.decompress(data), {}, use_embedded_server_options)
|
||||
self.data_filename = multidatapath
|
||||
|
||||
@staticmethod
|
||||
@@ -377,24 +382,24 @@ class Context:
|
||||
raise Utils.VersionException("Incompatible multidata.")
|
||||
return restricted_loads(zlib.decompress(data[1:]))
|
||||
|
||||
def _load(self, decoded_obj: dict, use_embedded_server_options: bool):
|
||||
def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
|
||||
use_embedded_server_options: bool):
|
||||
|
||||
self.read_data = {}
|
||||
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
||||
if mdata_ver > Utils.version_tuple:
|
||||
if mdata_ver > version_tuple:
|
||||
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
|
||||
f"however this server is of version {Utils.version_tuple}")
|
||||
f"however this server is of version {version_tuple}")
|
||||
self.generator_version = Version(*decoded_obj["version"])
|
||||
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
||||
self.minimum_client_versions = {}
|
||||
for player, version in clients_ver.items():
|
||||
self.minimum_client_versions[player] = max(Utils.Version(*version), min_client_version)
|
||||
self.minimum_client_versions[player] = max(Version(*version), min_client_version)
|
||||
|
||||
self.slot_info = decoded_obj["slot_info"]
|
||||
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
||||
self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
|
||||
if slot_info.type == SlotType.group}
|
||||
# TODO: around 0.4.2 or so, remove the if/else backwards compatibility check.
|
||||
self.allow_collect = {slot: slot_info.allow_collect if type(slot_info.allow_collect) is bool else True
|
||||
for slot, slot_info in self.slot_info.items()}
|
||||
|
||||
self.clients = {0: {}}
|
||||
slot_info: NetworkSlot
|
||||
@@ -411,7 +416,7 @@ class Context:
|
||||
self.seed_name = decoded_obj["seed_name"]
|
||||
self.random.seed(self.seed_name)
|
||||
self.connect_names = decoded_obj['connect_names']
|
||||
self.locations = decoded_obj['locations']
|
||||
self.locations = LocationStore(decoded_obj.pop("locations")) # pre-emptively free memory
|
||||
self.slot_data = decoded_obj['slot_data']
|
||||
for slot, data in self.slot_data.items():
|
||||
self.read_data[f"slot_data_{slot}"] = lambda data=data: data
|
||||
@@ -435,14 +440,17 @@ class Context:
|
||||
server_options = decoded_obj.get("server_options", {})
|
||||
self._set_options(server_options)
|
||||
|
||||
# custom datapackage
|
||||
# embedded data package
|
||||
for game_name, data in decoded_obj.get("datapackage", {}).items():
|
||||
logging.info(f"Loading custom datapackage for game {game_name}")
|
||||
if game_name in game_data_packages:
|
||||
data = game_data_packages[game_name]
|
||||
logging.info(f"Loading embedded data package for game {game_name}")
|
||||
self.gamespackage[game_name] = data
|
||||
self.item_name_groups[game_name] = data["item_name_groups"]
|
||||
self.location_name_groups[game_name] = data["location_name_groups"]
|
||||
del data["item_name_groups"] # remove from datapackage, but keep in self.item_name_groups
|
||||
del data["location_name_groups"]
|
||||
if "location_name_groups" in data:
|
||||
self.location_name_groups[game_name] = data["location_name_groups"]
|
||||
del data["location_name_groups"]
|
||||
del data["item_name_groups"] # remove from data package, but keep in self.item_name_groups
|
||||
self._init_game_data()
|
||||
for game_name, data in self.item_name_groups.items():
|
||||
self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame]
|
||||
@@ -538,7 +546,7 @@ class Context:
|
||||
"stored_data": self.stored_data,
|
||||
"game_options": {"hint_cost": self.hint_cost, "location_check_points": self.location_check_points,
|
||||
"server_password": self.server_password, "password": self.password,
|
||||
"forfeit_mode": self.release_mode, "release_mode": self.release_mode, # TODO remove forfeit_mode around 0.4
|
||||
"release_mode": self.release_mode,
|
||||
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
|
||||
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
||||
|
||||
@@ -591,7 +599,7 @@ class Context:
|
||||
|
||||
def get_hint_cost(self, slot):
|
||||
if self.hint_cost:
|
||||
return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot])))
|
||||
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
|
||||
return 0
|
||||
|
||||
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None):
|
||||
@@ -694,6 +702,10 @@ class Context:
|
||||
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
|
||||
if targets:
|
||||
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}])
|
||||
self.broadcast(self.clients[team][slot], [{
|
||||
"cmd": "RoomUpdate",
|
||||
"hint_points": get_slot_points(self, team, slot)
|
||||
}])
|
||||
|
||||
|
||||
def update_aliases(ctx: Context, team: int):
|
||||
@@ -739,19 +751,24 @@ async def on_client_connected(ctx: Context, client: Client):
|
||||
NetworkPlayer(team, slot,
|
||||
ctx.name_aliases.get((team, slot), name), name)
|
||||
)
|
||||
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
|
||||
games.add("Archipelago")
|
||||
await ctx.send_msgs(client, [{
|
||||
'cmd': 'RoomInfo',
|
||||
'password': bool(ctx.password),
|
||||
'games': {ctx.games[x] for x in range(1, len(ctx.games) + 1)},
|
||||
'games': games,
|
||||
# tags are for additional features in the communication.
|
||||
# Name them by feature or fork, as you feel is appropriate.
|
||||
'tags': ctx.tags,
|
||||
'version': Utils.version_tuple,
|
||||
'version': version_tuple,
|
||||
'generator_version': ctx.generator_version,
|
||||
'permissions': get_permissions(ctx),
|
||||
'hint_cost': ctx.hint_cost,
|
||||
'location_check_points': ctx.location_check_points,
|
||||
'datapackage_versions': {game: game_data["version"] for game, game_data
|
||||
in ctx.gamespackage.items()},
|
||||
in ctx.gamespackage.items() if game in games},
|
||||
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
|
||||
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
|
||||
'seed_name': ctx.seed_name,
|
||||
'time': time.time(),
|
||||
}])
|
||||
@@ -759,7 +776,6 @@ async def on_client_connected(ctx: Context, client: Client):
|
||||
|
||||
def get_permissions(ctx) -> typing.Dict[str, Permission]:
|
||||
return {
|
||||
"forfeit": Permission.from_text(ctx.release_mode), # TODO remove around 0.4
|
||||
"release": Permission.from_text(ctx.release_mode),
|
||||
"remaining": Permission.from_text(ctx.remaining_mode),
|
||||
"collect": Permission.from_text(ctx.collect_mode)
|
||||
@@ -772,13 +788,14 @@ async def on_client_disconnected(ctx: Context, client: Client):
|
||||
|
||||
|
||||
async def on_client_joined(ctx: Context, client: Client):
|
||||
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
|
||||
if ctx.client_game_state[client.team, client.slot] == ClientStatus.CLIENT_UNKNOWN:
|
||||
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
|
||||
version_str = '.'.join(str(x) for x in client.version)
|
||||
verb = "tracking" if "Tracker" in client.tags else "playing"
|
||||
ctx.broadcast_text_all(
|
||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
|
||||
f"{verb} {ctx.games[client.slot]} has joined. "
|
||||
f"Client({version_str}), {client.tags}).",
|
||||
f"Client({version_str}), {client.tags}.",
|
||||
{"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags})
|
||||
ctx.notify_client(client, "Now that you are connected, "
|
||||
"you can use !help to list commands to run via the server. "
|
||||
@@ -789,11 +806,12 @@ async def on_client_joined(ctx: Context, client: Client):
|
||||
|
||||
|
||||
async def on_client_left(ctx: Context, client: Client):
|
||||
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
|
||||
if len(ctx.clients[client.team][client.slot]) < 1:
|
||||
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
|
||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||
ctx.broadcast_text_all(
|
||||
"%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1),
|
||||
{"type": "Part", "team": client.team, "slot": client.slot})
|
||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
|
||||
async def countdown(ctx: Context, timer: int):
|
||||
@@ -887,13 +905,7 @@ def release_player(ctx: Context, team: int, slot: int):
|
||||
|
||||
def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
|
||||
"""register any locations that are in the multidata, pointing towards this player"""
|
||||
all_locations = collections.defaultdict(set)
|
||||
for source_slot, location_data in ctx.locations.items():
|
||||
if not ctx.allow_collect[source_slot]:
|
||||
continue
|
||||
for location_id, values in location_data.items():
|
||||
if values[1] == slot:
|
||||
all_locations[source_slot].add(location_id)
|
||||
all_locations = ctx.locations.get_for_player(slot)
|
||||
|
||||
ctx.broadcast_text_all("%s (Team #%d) has collected their items from other worlds."
|
||||
% (ctx.player_names[(team, slot)], team + 1),
|
||||
@@ -912,11 +924,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
|
||||
|
||||
|
||||
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
|
||||
items = []
|
||||
for location_id in ctx.locations[slot]:
|
||||
if location_id not in ctx.location_checks[team, slot]:
|
||||
items.append(ctx.locations[slot][location_id][0]) # item ID
|
||||
return sorted(items)
|
||||
return ctx.locations.get_remaining(ctx.location_checks, team, slot)
|
||||
|
||||
|
||||
def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem):
|
||||
@@ -964,13 +972,12 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
||||
slots.add(group_id)
|
||||
|
||||
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
||||
for finding_player, check_data in ctx.locations.items():
|
||||
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
|
||||
if receiving_player in slots and item_id == seeked_item_id:
|
||||
found = location_id in ctx.location_checks[team, finding_player]
|
||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
||||
item_flags))
|
||||
for finding_player, location_id, item_id, receiving_player, item_flags \
|
||||
in ctx.locations.find_item(slots, seeked_item_id):
|
||||
found = location_id in ctx.location_checks[team, finding_player]
|
||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
||||
item_flags))
|
||||
|
||||
return hints
|
||||
|
||||
@@ -1317,27 +1324,41 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
"Sorry, !remaining requires you to have beaten the game on this server")
|
||||
return False
|
||||
|
||||
def _cmd_missing(self) -> bool:
|
||||
"""List all missing location checks from the server's perspective"""
|
||||
def _cmd_missing(self, filter_text="") -> bool:
|
||||
"""List all missing location checks from the server's perspective.
|
||||
Can be given text, which will be used as filter."""
|
||||
|
||||
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
|
||||
|
||||
if locations:
|
||||
texts = [f'Missing: {self.ctx.location_names[location]}' for location in locations]
|
||||
texts.append(f"Found {len(locations)} missing location checks")
|
||||
names = [self.ctx.location_names[location] for location in locations]
|
||||
if filter_text:
|
||||
names = [name for name in names if filter_text in name]
|
||||
texts = [f'Missing: {name}' for name in names]
|
||||
if filter_text:
|
||||
texts.append(f"Found {len(locations)} missing location checks, displaying {len(names)} of them.")
|
||||
else:
|
||||
texts.append(f"Found {len(locations)} missing location checks")
|
||||
self.output_multiple(texts)
|
||||
else:
|
||||
self.output("No missing location checks found.")
|
||||
return True
|
||||
|
||||
def _cmd_checked(self) -> bool:
|
||||
"""List all done location checks from the server's perspective"""
|
||||
def _cmd_checked(self, filter_text="") -> bool:
|
||||
"""List all done location checks from the server's perspective.
|
||||
Can be given text, which will be used as filter."""
|
||||
|
||||
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
|
||||
|
||||
if locations:
|
||||
texts = [f'Checked: {self.ctx.location_names[location]}' for location in locations]
|
||||
texts.append(f"Found {len(locations)} done location checks")
|
||||
names = [self.ctx.location_names[location] for location in locations]
|
||||
if filter_text:
|
||||
names = [name for name in names if filter_text in name]
|
||||
texts = [f'Checked: {name}' for name in names]
|
||||
if filter_text:
|
||||
texts.append(f"Found {len(locations)} done location checks, displaying {len(names)} of them.")
|
||||
else:
|
||||
texts.append(f"Found {len(locations)} done location checks")
|
||||
self.output_multiple(texts)
|
||||
else:
|
||||
self.output("No done location checks found.")
|
||||
@@ -1528,15 +1549,11 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
|
||||
|
||||
def get_checked_checks(ctx: Context, team: int, slot: int) -> typing.List[int]:
|
||||
return [location_id for
|
||||
location_id in ctx.locations[slot] if
|
||||
location_id in ctx.location_checks[team, slot]]
|
||||
return ctx.locations.get_checked(ctx.location_checks, team, slot)
|
||||
|
||||
|
||||
def get_missing_checks(ctx: Context, team: int, slot: int) -> typing.List[int]:
|
||||
return [location_id for
|
||||
location_id in ctx.locations[slot] if
|
||||
location_id not in ctx.location_checks[team, slot]]
|
||||
return ctx.locations.get_missing(ctx.location_checks, team, slot)
|
||||
|
||||
|
||||
def get_client_points(ctx: Context, client: Client) -> int:
|
||||
@@ -1616,7 +1633,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
"players": ctx.get_players_package(),
|
||||
"missing_locations": get_missing_checks(ctx, team, slot),
|
||||
"checked_locations": get_checked_checks(ctx, team, slot),
|
||||
"slot_info": ctx.slot_info
|
||||
"slot_info": ctx.slot_info,
|
||||
"hint_points": get_slot_points(ctx, team, slot),
|
||||
}
|
||||
reply = [connected_packet]
|
||||
start_inventory = get_start_inventory(ctx, slot, client.remote_start_inventory)
|
||||
@@ -1718,6 +1736,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
|
||||
locs.append(NetworkItem(target_item, location, target_player, flags))
|
||||
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
|
||||
if locs and create_as_hint:
|
||||
ctx.save()
|
||||
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
||||
|
||||
elif cmd == 'StatusUpdate':
|
||||
@@ -1776,6 +1796,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
targets.add(client)
|
||||
if targets:
|
||||
ctx.broadcast(targets, [args])
|
||||
ctx.save()
|
||||
|
||||
elif cmd == "SetNotify":
|
||||
if "keys" not in args or type(args["keys"]) != list:
|
||||
@@ -1793,6 +1814,7 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
|
||||
ctx.on_goal_achieved(client)
|
||||
|
||||
ctx.client_game_state[client.team, client.slot] = new_status
|
||||
ctx.save()
|
||||
|
||||
|
||||
class ServerCommandProcessor(CommonCommandProcessor):
|
||||
@@ -1833,7 +1855,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
|
||||
def _cmd_exit(self) -> bool:
|
||||
"""Shutdown the server"""
|
||||
async_start(self.ctx.server.ws_server._close())
|
||||
self.ctx.server.ws_server.close()
|
||||
if self.ctx.shutdown_task:
|
||||
self.ctx.shutdown_task.cancel()
|
||||
self.ctx.exit_event.set()
|
||||
@@ -2096,13 +2118,15 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
async def console(ctx: Context):
|
||||
import sys
|
||||
queue = asyncio.Queue()
|
||||
Utils.stream_input(sys.stdin, queue)
|
||||
worker = Utils.stream_input(sys.stdin, queue)
|
||||
while not ctx.exit_event.is_set():
|
||||
try:
|
||||
# I don't get why this while loop is needed. Works fine without it on clients,
|
||||
# but the queue.get() for server never fulfills if the queue is empty when entering the await.
|
||||
while queue.qsize() == 0:
|
||||
await asyncio.sleep(0.05)
|
||||
if not worker.is_alive():
|
||||
return
|
||||
input_text = await queue.get()
|
||||
queue.task_done()
|
||||
ctx.commandprocessor(input_text)
|
||||
@@ -2113,7 +2137,7 @@ async def console(ctx: Context):
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
defaults = Utils.get_options()["server_options"]
|
||||
defaults = Utils.get_options()["server_options"].as_dict()
|
||||
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
|
||||
parser.add_argument('--host', default=defaults["host"])
|
||||
parser.add_argument('--port', default=defaults["port"], type=int)
|
||||
@@ -2174,7 +2198,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
||||
await asyncio.sleep(ctx.auto_shutdown)
|
||||
while not ctx.exit_event.is_set():
|
||||
if not ctx.client_activity_timers.values():
|
||||
async_start(ctx.server.ws_server._close())
|
||||
ctx.server.ws_server.close()
|
||||
ctx.exit_event.set()
|
||||
if to_cancel:
|
||||
for task in to_cancel:
|
||||
@@ -2185,7 +2209,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
||||
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
|
||||
seconds = ctx.auto_shutdown - delta.total_seconds()
|
||||
if seconds < 0:
|
||||
async_start(ctx.server.ws_server._close())
|
||||
ctx.server.ws_server.close()
|
||||
ctx.exit_event.set()
|
||||
if to_cancel:
|
||||
for task in to_cancel:
|
||||
@@ -2222,12 +2246,15 @@ async def main(args: argparse.Namespace):
|
||||
if not isinstance(e, ImportError):
|
||||
logging.error(f"Failed to load tkinter ({e})")
|
||||
logging.info("Pass a multidata filename on command line to run headless.")
|
||||
exit(1)
|
||||
# when cx_Freeze'd the built-in exit is not available, so we import sys.exit instead
|
||||
import sys
|
||||
sys.exit(1)
|
||||
raise
|
||||
|
||||
if not data_filename:
|
||||
logging.info("No file selected. Exiting.")
|
||||
exit(1)
|
||||
import sys
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
ctx.load(data_filename, args.use_embedded_options)
|
||||
@@ -2240,8 +2267,7 @@ async def main(args: argparse.Namespace):
|
||||
|
||||
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
|
||||
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ping_timeout=None,
|
||||
ping_interval=None, ssl=ssl_context)
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context)
|
||||
ip = args.host if args.host else Utils.get_public_ipv4()
|
||||
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
|
||||
'No password' if not ctx.password else 'Password: %s' % ctx.password))
|
||||
|
||||
94
NetUtils.py
@@ -2,11 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import enum
|
||||
import warnings
|
||||
from json import JSONEncoder, JSONDecoder
|
||||
|
||||
import websockets
|
||||
|
||||
from Utils import Version
|
||||
from Utils import ByValue, Version
|
||||
|
||||
|
||||
class JSONMessagePart(typing.TypedDict, total=False):
|
||||
@@ -20,7 +21,7 @@ class JSONMessagePart(typing.TypedDict, total=False):
|
||||
flags: int
|
||||
|
||||
|
||||
class ClientStatus(enum.IntEnum):
|
||||
class ClientStatus(ByValue, enum.IntEnum):
|
||||
CLIENT_UNKNOWN = 0
|
||||
CLIENT_CONNECTED = 5
|
||||
CLIENT_READY = 10
|
||||
@@ -28,18 +29,18 @@ class ClientStatus(enum.IntEnum):
|
||||
CLIENT_GOAL = 30
|
||||
|
||||
|
||||
class SlotType(enum.IntFlag):
|
||||
class SlotType(ByValue, enum.IntFlag):
|
||||
spectator = 0b00
|
||||
player = 0b01
|
||||
group = 0b10
|
||||
|
||||
@property
|
||||
def always_goal(self) -> bool:
|
||||
"""Mark this slot has having reached its goal instantly."""
|
||||
"""Mark this slot as having reached its goal instantly."""
|
||||
return self.value != 0b01
|
||||
|
||||
|
||||
class Permission(enum.IntFlag):
|
||||
class Permission(ByValue, enum.IntFlag):
|
||||
disabled = 0b000 # 0, completely disables access
|
||||
enabled = 0b001 # 1, allows manual use
|
||||
goal = 0b010 # 2, allows manual use after goal completion
|
||||
@@ -71,7 +72,6 @@ class NetworkSlot(typing.NamedTuple):
|
||||
name: str
|
||||
game: str
|
||||
type: SlotType
|
||||
allow_collect: bool = True
|
||||
group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
|
||||
|
||||
|
||||
@@ -344,3 +344,85 @@ class Hint(typing.NamedTuple):
|
||||
@property
|
||||
def local(self):
|
||||
return self.receiving_player == self.finding_player
|
||||
|
||||
|
||||
class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]):
|
||||
def __init__(self, values: typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]):
|
||||
super().__init__(values)
|
||||
|
||||
if not self:
|
||||
raise ValueError(f"Rejecting game with 0 players")
|
||||
|
||||
if len(self) != max(self):
|
||||
raise ValueError("Player IDs not continuous")
|
||||
|
||||
if len(self.get(0, {})):
|
||||
raise ValueError("Invalid player id 0 for location")
|
||||
|
||||
def find_item(self, slots: typing.Set[int], seeked_item_id: int
|
||||
) -> typing.Generator[typing.Tuple[int, int, int, int, int], None, None]:
|
||||
for finding_player, check_data in self.items():
|
||||
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
|
||||
if receiving_player in slots and item_id == seeked_item_id:
|
||||
yield finding_player, location_id, item_id, receiving_player, item_flags
|
||||
|
||||
def get_for_player(self, slot: int) -> typing.Dict[int, typing.Set[int]]:
|
||||
import collections
|
||||
all_locations: typing.Dict[int, typing.Set[int]] = collections.defaultdict(set)
|
||||
for source_slot, location_data in self.items():
|
||||
for location_id, values in location_data.items():
|
||||
if values[1] == slot:
|
||||
all_locations[source_slot].add(location_id)
|
||||
return all_locations
|
||||
|
||||
def get_checked(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
||||
) -> typing.List[int]:
|
||||
checked = state[team, slot]
|
||||
if not checked:
|
||||
# This optimizes the case where everyone connects to a fresh game at the same time.
|
||||
return []
|
||||
return [location_id for
|
||||
location_id in self[slot] if
|
||||
location_id in checked]
|
||||
|
||||
def get_missing(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
||||
) -> typing.List[int]:
|
||||
checked = state[team, slot]
|
||||
if not checked:
|
||||
# This optimizes the case where everyone connects to a fresh game at the same time.
|
||||
return list(self[slot])
|
||||
return [location_id for
|
||||
location_id in self[slot] if
|
||||
location_id not in checked]
|
||||
|
||||
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
|
||||
) -> typing.List[int]:
|
||||
checked = state[team, slot]
|
||||
player_locations = self[slot]
|
||||
return sorted([player_locations[location_id][0] for
|
||||
location_id in player_locations if
|
||||
location_id not in checked])
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
|
||||
LocationStore = _LocationStore
|
||||
else:
|
||||
try:
|
||||
from _speedups import LocationStore
|
||||
import _speedups
|
||||
import os.path
|
||||
if os.path.isfile("_speedups.pyx") and os.path.getctime(_speedups.__file__) < os.path.getctime("_speedups.pyx"):
|
||||
warnings.warn(f"{_speedups.__file__} outdated! "
|
||||
f"Please rebuild with `cythonize -b -i _speedups.pyx` or delete it!")
|
||||
except ImportError:
|
||||
try:
|
||||
import pyximport
|
||||
pyximport.install()
|
||||
except ImportError:
|
||||
pyximport = None
|
||||
try:
|
||||
from _speedups import LocationStore
|
||||
except ImportError:
|
||||
warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
|
||||
"Install a matching C++ compiler for your platform to compile _speedups.")
|
||||
LocationStore = _LocationStore
|
||||
|
||||
@@ -44,7 +44,7 @@ def adjustGUI():
|
||||
StringVar, IntVar, Checkbutton, Frame, Label, X, Entry, Button, \
|
||||
OptionMenu, filedialog, messagebox, ttk
|
||||
from argparse import Namespace
|
||||
from Main import __version__ as MWVersion
|
||||
from Utils import __version__ as MWVersion
|
||||
|
||||
window = tk.Tk()
|
||||
window.wm_title(f"Archipelago {MWVersion} OoT Adjuster")
|
||||
|
||||
30
OoTClient.py
@@ -17,9 +17,9 @@ from worlds.oot.N64Patch import apply_patch_file
|
||||
from worlds.oot.Utils import data_path
|
||||
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart oot_connector.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure oot_connector.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart oot_connector.lua"
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_oot.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure connector_oot.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_oot.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
@@ -100,7 +100,7 @@ class OoTContext(CommonContext):
|
||||
await super(OoTContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to Bizhawk to get player information')
|
||||
logger.info('Awaiting connection to EmuHawk to get player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
@@ -179,6 +179,12 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
locations = payload['locations']
|
||||
collectibles = payload['collectibles']
|
||||
|
||||
# The Lua JSON library serializes an empty table into a list instead of a dict. Verify types for safety:
|
||||
if isinstance(locations, list):
|
||||
locations = {}
|
||||
if isinstance(collectibles, list):
|
||||
collectibles = {}
|
||||
|
||||
if ctx.location_table != locations or ctx.collectible_table != collectibles:
|
||||
ctx.location_table = locations
|
||||
ctx.collectible_table = collectibles
|
||||
@@ -289,11 +295,17 @@ async def patch_and_run_game(apz5_file):
|
||||
decomp_path = base_name + '-decomp.z64'
|
||||
comp_path = base_name + '.z64'
|
||||
# Load vanilla ROM, patch file, compress ROM
|
||||
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
|
||||
apply_patch_file(rom, apz5_file,
|
||||
sub_file=(os.path.basename(base_name) + '.zpf'
|
||||
if zipfile.is_zipfile(apz5_file)
|
||||
else None))
|
||||
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
|
||||
rom = Rom(rom_file_name)
|
||||
|
||||
sub_file = None
|
||||
if zipfile.is_zipfile(apz5_file):
|
||||
for name in zipfile.ZipFile(apz5_file).namelist():
|
||||
if name.endswith('.zpf'):
|
||||
sub_file = name
|
||||
break
|
||||
|
||||
apply_patch_file(rom, apz5_file, sub_file=sub_file)
|
||||
rom.write_to_file(decomp_path)
|
||||
os.chdir(data_path("Compress"))
|
||||
compress_rom_file(decomp_path, comp_path)
|
||||
|
||||
118
Options.py
@@ -1,18 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
import math
|
||||
import numbers
|
||||
import typing
|
||||
import random
|
||||
import typing
|
||||
from copy import deepcopy
|
||||
|
||||
from schema import And, Optional, Or, Schema
|
||||
|
||||
from schema import Schema, And, Or, Optional
|
||||
from Utils import get_fuzzy_results
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from BaseClasses import PlandoOptions
|
||||
from worlds.AutoWorld import World
|
||||
import pathlib
|
||||
|
||||
|
||||
class AssembleOptions(abc.ABCMeta):
|
||||
@@ -715,8 +718,16 @@ class SpecialRange(Range):
|
||||
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||
|
||||
|
||||
class VerifyKeys:
|
||||
valid_keys = frozenset()
|
||||
class FreezeValidKeys(AssembleOptions):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
if "valid_keys" in attrs:
|
||||
attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
|
||||
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class VerifyKeys(metaclass=FreezeValidKeys):
|
||||
valid_keys: typing.Iterable = []
|
||||
_valid_keys: frozenset # gets created by AssembleOptions from valid_keys
|
||||
valid_keys_casefold: bool = False
|
||||
convert_name_groups: bool = False
|
||||
verify_item_name: bool = False
|
||||
@@ -728,10 +739,10 @@ class VerifyKeys:
|
||||
if cls.valid_keys:
|
||||
data = set(data)
|
||||
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
|
||||
extra = dataset - cls.valid_keys
|
||||
extra = dataset - cls._valid_keys
|
||||
if extra:
|
||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||
f"Allowed keys: {cls.valid_keys}.")
|
||||
f"Allowed keys: {cls._valid_keys}.")
|
||||
|
||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||
if self.convert_name_groups and self.verify_item_name:
|
||||
@@ -760,7 +771,7 @@ class VerifyKeys:
|
||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||
|
||||
|
||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
|
||||
default: typing.Dict[str, typing.Any] = {}
|
||||
supports_weighting = False
|
||||
|
||||
@@ -778,8 +789,14 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
||||
def get_option_name(self, value):
|
||||
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self.value
|
||||
def __getitem__(self, item: str) -> typing.Any:
|
||||
return self.value.__getitem__(item)
|
||||
|
||||
def __iter__(self) -> typing.Iterator[str]:
|
||||
return self.value.__iter__()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.value.__len__()
|
||||
|
||||
|
||||
class ItemDict(OptionDict):
|
||||
@@ -792,6 +809,10 @@ class ItemDict(OptionDict):
|
||||
|
||||
|
||||
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
|
||||
# Supports duplicate entries and ordering.
|
||||
# If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead.
|
||||
# Not a docstring so it doesn't get grabbed by the options system.
|
||||
|
||||
default: typing.List[typing.Any] = []
|
||||
supports_weighting = False
|
||||
|
||||
@@ -875,17 +896,9 @@ class ProgressionBalancing(SpecialRange):
|
||||
}
|
||||
|
||||
|
||||
class AllowCollect(DefaultOnToggle):
|
||||
"""Controls whether items are collected from the slot when a player does a !collect or not.
|
||||
The impact for the collecting player is that the collector might not get all of their items, until
|
||||
the player(s) that has disallowed collection actually completes or releases their location checks."""
|
||||
display_name = "Allow Collect"
|
||||
|
||||
|
||||
common_options = {
|
||||
"progression_balancing": ProgressionBalancing,
|
||||
"accessibility": Accessibility,
|
||||
"allow_collect": AllowCollect
|
||||
"accessibility": Accessibility
|
||||
}
|
||||
|
||||
|
||||
@@ -905,6 +918,13 @@ class StartInventory(ItemDict):
|
||||
display_name = "Start Inventory"
|
||||
|
||||
|
||||
class StartInventoryPool(StartInventory):
|
||||
"""Start with these items and don't place them in the world.
|
||||
The game decides what the replacement items will be."""
|
||||
verify_item_name = True
|
||||
display_name = "Start Inventory from Pool"
|
||||
|
||||
|
||||
class StartHints(ItemSet):
|
||||
"""Start with these item's locations prefilled into the !hint command."""
|
||||
display_name = "Start Hints"
|
||||
@@ -912,6 +932,7 @@ class StartHints(ItemSet):
|
||||
|
||||
class LocationSet(OptionSet):
|
||||
verify_location_name = True
|
||||
convert_name_groups = True
|
||||
|
||||
|
||||
class StartLocationHints(LocationSet):
|
||||
@@ -936,6 +957,7 @@ class DeathLink(Toggle):
|
||||
|
||||
class ItemLinks(OptionList):
|
||||
"""Share part of your item pool with other players."""
|
||||
display_name = "Item Links"
|
||||
default = []
|
||||
schema = Schema([
|
||||
{
|
||||
@@ -1010,6 +1032,64 @@ per_game_common_options = {
|
||||
"item_links": ItemLinks
|
||||
}
|
||||
|
||||
|
||||
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
|
||||
import os
|
||||
|
||||
import yaml
|
||||
from jinja2 import Template
|
||||
|
||||
from worlds import AutoWorldRegister
|
||||
from Utils import local_path, __version__
|
||||
|
||||
full_path: str
|
||||
|
||||
os.makedirs(target_folder, exist_ok=True)
|
||||
|
||||
# clean out old
|
||||
for file in os.listdir(target_folder):
|
||||
full_path = os.path.join(target_folder, file)
|
||||
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
|
||||
os.unlink(full_path)
|
||||
|
||||
def dictify_range(option: typing.Union[Range, SpecialRange]):
|
||||
data = {option.default: 50}
|
||||
for sub_option in ["random", "random-low", "random-high"]:
|
||||
if sub_option != option.default:
|
||||
data[sub_option] = 0
|
||||
|
||||
notes = {}
|
||||
for name, number in getattr(option, "special_range_names", {}).items():
|
||||
notes[name] = f"equivalent to {number}"
|
||||
if number in data:
|
||||
data[name] = data[number]
|
||||
del data[number]
|
||||
else:
|
||||
data[name] = 0
|
||||
|
||||
return data, notes
|
||||
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden or generate_hidden:
|
||||
all_options: typing.Dict[str, AssembleOptions] = {
|
||||
**per_game_common_options,
|
||||
**world.option_definitions
|
||||
}
|
||||
|
||||
with open(local_path("data", "options.yaml")) as f:
|
||||
file_data = f.read()
|
||||
res = Template(file_data).render(
|
||||
options=all_options,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||
dictify_range=dictify_range,
|
||||
)
|
||||
|
||||
del file_data
|
||||
|
||||
with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
|
||||
f.write(res)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
from worlds.alttp.Options import Logic
|
||||
|
||||
@@ -29,6 +29,9 @@ for location in location_data:
|
||||
location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address
|
||||
location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit}
|
||||
|
||||
location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item"
|
||||
and location.address is not None}
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
|
||||
@@ -72,13 +75,14 @@ class GBContext(CommonContext):
|
||||
self.items_handling = 0b001
|
||||
self.sent_release = False
|
||||
self.sent_collect = False
|
||||
self.auto_hints = set()
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(GBContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to Bizhawk to get Player information')
|
||||
logger.info('Awaiting connection to EmuHawk to get Player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
@@ -153,6 +157,33 @@ async def parse_locations(data: List, ctx: GBContext):
|
||||
locations.append(loc_id)
|
||||
elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
|
||||
locations.append(loc_id)
|
||||
|
||||
hints = []
|
||||
if flags["EventFlag"][280] & 16:
|
||||
hints.append("Cerulean Bicycle Shop")
|
||||
if flags["EventFlag"][280] & 32:
|
||||
hints.append("Route 2 Gate - Oak's Aide")
|
||||
if flags["EventFlag"][280] & 64:
|
||||
hints.append("Route 11 Gate 2F - Oak's Aide")
|
||||
if flags["EventFlag"][280] & 128:
|
||||
hints.append("Route 15 Gate 2F - Oak's Aide")
|
||||
if flags["EventFlag"][281] & 1:
|
||||
hints += ["Celadon Prize Corner - Item Prize 1", "Celadon Prize Corner - Item Prize 2",
|
||||
"Celadon Prize Corner - Item Prize 3"]
|
||||
if (location_name_to_id["Fossil - Choice A"] in ctx.checked_locations and location_name_to_id["Fossil - Choice B"]
|
||||
not in ctx.checked_locations):
|
||||
hints.append("Fossil - Choice B")
|
||||
elif (location_name_to_id["Fossil - Choice B"] in ctx.checked_locations and location_name_to_id["Fossil - Choice A"]
|
||||
not in ctx.checked_locations):
|
||||
hints.append("Fossil - Choice A")
|
||||
hints = [
|
||||
location_name_to_id[loc] for loc in hints if location_name_to_id[loc] not in ctx.auto_hints and
|
||||
location_name_to_id[loc] in ctx.missing_locations and location_name_to_id[loc] not in ctx.locations_checked
|
||||
]
|
||||
if hints:
|
||||
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": hints, "create_as_hint": 2}])
|
||||
ctx.auto_hints.update(hints)
|
||||
|
||||
if flags["EventFlag"][280] & 1 and not ctx.finished_game:
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "StatusUpdate",
|
||||
|
||||
12
README.md
@@ -39,6 +39,18 @@ Currently, the following games are supported:
|
||||
* Stardew Valley
|
||||
* The Legend of Zelda
|
||||
* The Messenger
|
||||
* Kingdom Hearts 2
|
||||
* The Legend of Zelda: Link's Awakening DX
|
||||
* Clique
|
||||
* Adventure
|
||||
* DLC Quest
|
||||
* Noita
|
||||
* Undertale
|
||||
* Bumper Stickers
|
||||
* Mega Man Battle Network 3: Blue Version
|
||||
* Muse Dash
|
||||
* DOOM 1993
|
||||
* Terraria
|
||||
|
||||
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
|
||||
|
||||
33
SNIClient.py
@@ -68,12 +68,11 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
|
||||
options = snes_options.split()
|
||||
num_options = len(options)
|
||||
|
||||
if num_options > 0:
|
||||
snes_device_number = int(options[0])
|
||||
|
||||
if num_options > 1:
|
||||
snes_address = options[0]
|
||||
snes_device_number = int(options[1])
|
||||
elif num_options > 0:
|
||||
snes_device_number = int(options[0])
|
||||
|
||||
self.ctx.snes_reconnect_address = None
|
||||
if self.ctx.snes_connect_task:
|
||||
@@ -115,8 +114,8 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
|
||||
|
||||
class SNIContext(CommonContext):
|
||||
command_processor: typing.Type[SNIClientCommandProcessor] = SNIClientCommandProcessor
|
||||
game = None # set in validate_rom
|
||||
items_handling = None # set in game_watcher
|
||||
game: typing.Optional[str] = None # set in validate_rom
|
||||
items_handling: typing.Optional[int] = None # set in game_watcher
|
||||
snes_connect_task: "typing.Optional[asyncio.Task[None]]" = None
|
||||
snes_autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None
|
||||
|
||||
@@ -315,7 +314,7 @@ def launch_sni() -> None:
|
||||
f"please start it yourself if it is not running")
|
||||
|
||||
|
||||
async def _snes_connect(ctx: SNIContext, address: str) -> WebSocketClientProtocol:
|
||||
async def _snes_connect(ctx: SNIContext, address: str, retry: bool = True) -> WebSocketClientProtocol:
|
||||
address = f"ws://{address}" if "://" not in address else address
|
||||
snes_logger.info("Connecting to SNI at %s ..." % address)
|
||||
seen_problems: typing.Set[str] = set()
|
||||
@@ -336,6 +335,8 @@ async def _snes_connect(ctx: SNIContext, address: str) -> WebSocketClientProtoco
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
return snes_socket
|
||||
if not retry:
|
||||
break
|
||||
|
||||
|
||||
class SNESRequest(typing.TypedDict):
|
||||
@@ -563,14 +564,16 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int,
|
||||
PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
|
||||
try:
|
||||
for address, data in write_list:
|
||||
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
|
||||
# REVIEW: above: `if snes_socket is None: return False`
|
||||
# Does it need to be checked again?
|
||||
if ctx.snes_socket is not None:
|
||||
await ctx.snes_socket.send(dumps(PutAddress_Request))
|
||||
await ctx.snes_socket.send(data)
|
||||
else:
|
||||
snes_logger.warning(f"Could not send data to SNES: {data}")
|
||||
while data:
|
||||
# Divide the write into packets of 256 bytes.
|
||||
PutAddress_Request['Operands'] = [hex(address)[2:], hex(min(len(data), 256))[2:]]
|
||||
if ctx.snes_socket is not None:
|
||||
await ctx.snes_socket.send(dumps(PutAddress_Request))
|
||||
await ctx.snes_socket.send(data[:256])
|
||||
address += 256
|
||||
data = data[256:]
|
||||
else:
|
||||
snes_logger.warning(f"Could not send data to SNES: {data}")
|
||||
except ConnectionClosed:
|
||||
return False
|
||||
|
||||
@@ -684,6 +687,8 @@ async def main() -> None:
|
||||
logging.info(f"Wrote rom file to {romfile}")
|
||||
if args.diff_file.endswith(".apsoe"):
|
||||
import webbrowser
|
||||
async_start(run_game(romfile))
|
||||
await _snes_connect(SNIContext(args.snes, args.connect, args.password), args.snes, False)
|
||||
webbrowser.open(f"http://www.evermizer.com/apclient/#server={meta['server']}")
|
||||
logging.info("Starting Evermizer Client in your Browser...")
|
||||
import time
|
||||
|
||||
1053
Starcraft2Client.py
512
UndertaleClient.py
Normal file
@@ -0,0 +1,512 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import typing
|
||||
import bsdiff4
|
||||
import shutil
|
||||
|
||||
import Utils
|
||||
|
||||
from NetUtils import NetworkItem, ClientStatus
|
||||
from worlds import undertale
|
||||
from MultiServer import mark_raw
|
||||
from CommonClient import CommonContext, server_loop, \
|
||||
gui_enabled, ClientCommandProcessor, logger, get_base_parser
|
||||
from Utils import async_start
|
||||
|
||||
|
||||
class UndertaleCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
self.output(f"Syncing items.")
|
||||
self.ctx.syncing = True
|
||||
|
||||
def _cmd_patch(self):
|
||||
"""Patch the game."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
|
||||
self.ctx.patch_game()
|
||||
self.output("Patched.")
|
||||
|
||||
def _cmd_savepath(self, directory: str):
|
||||
"""Redirect to proper save data folder. (Use before connecting!)"""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
self.ctx.save_game_folder = directory
|
||||
self.output("Changed to the following directory: " + self.ctx.save_game_folder)
|
||||
|
||||
@mark_raw
|
||||
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
|
||||
"""Patch the game automatically."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
|
||||
tempInstall = steaminstall
|
||||
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
||||
tempInstall = None
|
||||
if tempInstall is None:
|
||||
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
|
||||
if not os.path.exists(tempInstall):
|
||||
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
|
||||
elif not os.path.exists(tempInstall):
|
||||
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
|
||||
if not os.path.exists(tempInstall):
|
||||
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
|
||||
if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")):
|
||||
self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder."
|
||||
" command. \"/auto_patch (Steam directory)\".")
|
||||
else:
|
||||
for file_name in os.listdir(tempInstall):
|
||||
if file_name != "steam_api.dll":
|
||||
shutil.copy(os.path.join(tempInstall, file_name),
|
||||
os.path.join(os.getcwd(), "Undertale", file_name))
|
||||
self.ctx.patch_game()
|
||||
self.output("Patching successful!")
|
||||
|
||||
def _cmd_online(self):
|
||||
"""Makes you no longer able to see other Undertale players."""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
self.ctx.update_online_mode(not ("Online" in self.ctx.tags))
|
||||
if "Online" in self.ctx.tags:
|
||||
self.output(f"Now online.")
|
||||
else:
|
||||
self.output(f"Now offline.")
|
||||
|
||||
def _cmd_deathlink(self):
|
||||
"""Toggles deathlink"""
|
||||
if isinstance(self.ctx, UndertaleContext):
|
||||
self.ctx.deathlink_status = not self.ctx.deathlink_status
|
||||
if self.ctx.deathlink_status:
|
||||
self.output(f"Deathlink enabled.")
|
||||
else:
|
||||
self.output(f"Deathlink disabled.")
|
||||
|
||||
|
||||
class UndertaleContext(CommonContext):
|
||||
tags = {"AP", "Online"}
|
||||
game = "Undertale"
|
||||
command_processor = UndertaleCommandProcessor
|
||||
items_handling = 0b111
|
||||
route = None
|
||||
pieces_needed = None
|
||||
completed_routes = None
|
||||
completed_count = 0
|
||||
save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.pieces_needed = 0
|
||||
self.finished_game = False
|
||||
self.game = "Undertale"
|
||||
self.got_deathlink = False
|
||||
self.syncing = False
|
||||
self.deathlink_status = False
|
||||
self.tem_armor = False
|
||||
self.completed_count = 0
|
||||
self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0}
|
||||
# self.save_game_folder: files go in this path to pass data between us and the actual game
|
||||
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
|
||||
|
||||
def patch_game(self):
|
||||
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f:
|
||||
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
|
||||
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f:
|
||||
f.write(patchedFile)
|
||||
os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True)
|
||||
with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites",
|
||||
"Which Character.txt")), "w") as f:
|
||||
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
|
||||
"line other than this one.\n", "frisk"])
|
||||
f.close()
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super().server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
def clear_undertale_files(self):
|
||||
path = self.save_game_folder
|
||||
self.finished_game = False
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if "check.spot" == file or "scout" == file:
|
||||
os.remove(os.path.join(root, file))
|
||||
elif file.endswith((".item", ".victory", ".route", ".playerspot", ".mad",
|
||||
".youDied", ".LV", ".mine", ".flag", ".hint")):
|
||||
os.remove(os.path.join(root, file))
|
||||
|
||||
async def connect(self, address: typing.Optional[str] = None):
|
||||
self.clear_undertale_files()
|
||||
await super().connect(address)
|
||||
|
||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||
self.clear_undertale_files()
|
||||
await super().disconnect(allow_autoreconnect)
|
||||
|
||||
async def connection_closed(self):
|
||||
self.clear_undertale_files()
|
||||
await super().connection_closed()
|
||||
|
||||
async def shutdown(self):
|
||||
self.clear_undertale_files()
|
||||
await super().shutdown()
|
||||
|
||||
def update_online_mode(self, online):
|
||||
old_tags = self.tags.copy()
|
||||
if online:
|
||||
self.tags.add("Online")
|
||||
else:
|
||||
self.tags -= {"Online"}
|
||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||
async_start(self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]))
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.slot_info[self.slot].game
|
||||
async_start(process_undertale_cmd(self, cmd, args))
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class UTManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Undertale Client"
|
||||
|
||||
self.ui = UTManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_deathlink(self, data: typing.Dict[str, typing.Any]):
|
||||
self.got_deathlink = True
|
||||
super().on_deathlink(data)
|
||||
|
||||
|
||||
def to_room_name(place_name: str):
|
||||
if place_name == "Old Home Exit":
|
||||
return "room_ruinsexit"
|
||||
elif place_name == "Snowdin Forest":
|
||||
return "room_tundra1"
|
||||
elif place_name == "Snowdin Town Exit":
|
||||
return "room_fogroom"
|
||||
elif place_name == "Waterfall":
|
||||
return "room_water1"
|
||||
elif place_name == "Waterfall Exit":
|
||||
return "room_fire2"
|
||||
elif place_name == "Hotland":
|
||||
return "room_fire_prelab"
|
||||
elif place_name == "Hotland Exit":
|
||||
return "room_fire_precore"
|
||||
elif place_name == "Core":
|
||||
return "room_fire_core1"
|
||||
|
||||
|
||||
async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
if not os.path.exists(ctx.save_game_folder):
|
||||
os.mkdir(ctx.save_game_folder)
|
||||
ctx.route = args["slot_data"]["route"]
|
||||
ctx.pieces_needed = args["slot_data"]["key_pieces"]
|
||||
ctx.tem_armor = args["slot_data"]["temy_armor_include"]
|
||||
|
||||
await ctx.send_msgs([{"cmd": "Get", "keys": [str(ctx.slot)+" RoutesDone neutral",
|
||||
str(ctx.slot)+" RoutesDone pacifist",
|
||||
str(ctx.slot)+" RoutesDone genocide"]}])
|
||||
await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral",
|
||||
str(ctx.slot)+" RoutesDone pacifist",
|
||||
str(ctx.slot)+" RoutesDone genocide"]}])
|
||||
if args["slot_data"]["only_flakes"]:
|
||||
with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f:
|
||||
f.close()
|
||||
if not args["slot_data"]["key_hunt"]:
|
||||
ctx.pieces_needed = 0
|
||||
if args["slot_data"]["rando_love"]:
|
||||
filename = f"LOVErando.LV"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.close()
|
||||
if args["slot_data"]["rando_stats"]:
|
||||
filename = f"STATrando.LV"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.close()
|
||||
filename = f"{ctx.route}.route"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.close()
|
||||
filename = f"check.spot"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
|
||||
for ss in set(args["checked_locations"]):
|
||||
f.write(str(ss-12000)+"\n")
|
||||
f.close()
|
||||
elif cmd == "LocationInfo":
|
||||
for l in args["locations"]:
|
||||
locationid = l.location
|
||||
filename = f"{str(locationid-12000)}.hint"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
toDraw = ""
|
||||
for i in range(20):
|
||||
if i < len(str(ctx.item_names[l.item])):
|
||||
toDraw += str(ctx.item_names[l.item])[i]
|
||||
else:
|
||||
break
|
||||
f.write(toDraw)
|
||||
f.close()
|
||||
elif cmd == "Retrieved":
|
||||
if str(ctx.slot)+" RoutesDone neutral" in args["keys"]:
|
||||
if args["keys"][str(ctx.slot)+" RoutesDone neutral"] is not None:
|
||||
ctx.completed_routes["neutral"] = args["keys"][str(ctx.slot)+" RoutesDone neutral"]
|
||||
if str(ctx.slot)+" RoutesDone genocide" in args["keys"]:
|
||||
if args["keys"][str(ctx.slot)+" RoutesDone genocide"] is not None:
|
||||
ctx.completed_routes["genocide"] = args["keys"][str(ctx.slot)+" RoutesDone genocide"]
|
||||
if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]:
|
||||
if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None:
|
||||
ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"]
|
||||
elif cmd == "SetReply":
|
||||
if args["value"] is not None:
|
||||
if str(ctx.slot)+" RoutesDone pacifist" == args["key"]:
|
||||
ctx.completed_routes["pacifist"] = args["value"]
|
||||
elif str(ctx.slot)+" RoutesDone genocide" == args["key"]:
|
||||
ctx.completed_routes["genocide"] = args["value"]
|
||||
elif str(ctx.slot)+" RoutesDone neutral" == args["key"]:
|
||||
ctx.completed_routes["neutral"] = args["value"]
|
||||
elif cmd == "ReceivedItems":
|
||||
start_index = args["index"]
|
||||
|
||||
if start_index == 0:
|
||||
ctx.items_received = []
|
||||
elif start_index != len(ctx.items_received):
|
||||
sync_msg = [{"cmd": "Sync"}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks",
|
||||
"locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
if start_index == len(ctx.items_received):
|
||||
counter = -1
|
||||
placedWeapon = 0
|
||||
placedArmor = 0
|
||||
for item in args["items"]:
|
||||
id = NetworkItem(*item).location
|
||||
while NetworkItem(*item).location < 0 and \
|
||||
counter <= id:
|
||||
id -= 1
|
||||
if NetworkItem(*item).location < 0:
|
||||
counter -= 1
|
||||
filename = f"{str(id)}PLR{str(NetworkItem(*item).player)}.item"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
if NetworkItem(*item).item == 77701:
|
||||
if placedWeapon == 0:
|
||||
f.write(str(77013-11000))
|
||||
elif placedWeapon == 1:
|
||||
f.write(str(77014-11000))
|
||||
elif placedWeapon == 2:
|
||||
f.write(str(77025-11000))
|
||||
elif placedWeapon == 3:
|
||||
f.write(str(77045-11000))
|
||||
elif placedWeapon == 4:
|
||||
f.write(str(77049-11000))
|
||||
elif placedWeapon == 5:
|
||||
f.write(str(77047-11000))
|
||||
elif placedWeapon == 6:
|
||||
if str(ctx.route) == "genocide" or str(ctx.route) == "all_routes":
|
||||
f.write(str(77052-11000))
|
||||
else:
|
||||
f.write(str(77051-11000))
|
||||
else:
|
||||
f.write(str(77003-11000))
|
||||
placedWeapon += 1
|
||||
elif NetworkItem(*item).item == 77702:
|
||||
if placedArmor == 0:
|
||||
f.write(str(77012-11000))
|
||||
elif placedArmor == 1:
|
||||
f.write(str(77015-11000))
|
||||
elif placedArmor == 2:
|
||||
f.write(str(77024-11000))
|
||||
elif placedArmor == 3:
|
||||
f.write(str(77044-11000))
|
||||
elif placedArmor == 4:
|
||||
f.write(str(77048-11000))
|
||||
elif placedArmor == 5:
|
||||
if str(ctx.route) == "genocide":
|
||||
f.write(str(77053-11000))
|
||||
else:
|
||||
f.write(str(77046-11000))
|
||||
elif placedArmor == 6 and ((not str(ctx.route) == "genocide") or ctx.tem_armor):
|
||||
if str(ctx.route) == "all_routes":
|
||||
f.write(str(77053-11000))
|
||||
elif str(ctx.route) == "genocide":
|
||||
f.write(str(77064-11000))
|
||||
else:
|
||||
f.write(str(77050-11000))
|
||||
elif placedArmor == 7 and ctx.tem_armor and not str(ctx.route) == "genocide":
|
||||
f.write(str(77064-11000))
|
||||
else:
|
||||
f.write(str(77004-11000))
|
||||
placedArmor += 1
|
||||
else:
|
||||
f.write(str(NetworkItem(*item).item-11000))
|
||||
f.close()
|
||||
ctx.items_received.append(NetworkItem(*item))
|
||||
if [item.item for item in ctx.items_received].count(77000) >= ctx.pieces_needed > 0:
|
||||
filename = f"{str(-99999)}PLR{str(0)}.item"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.write(str(77787 - 11000))
|
||||
f.close()
|
||||
filename = f"{str(-99998)}PLR{str(0)}.item"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.write(str(77789 - 11000))
|
||||
f.close()
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == "RoomUpdate":
|
||||
if "checked_locations" in args:
|
||||
filename = f"check.spot"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "a") as f:
|
||||
for ss in set(args["checked_locations"]):
|
||||
f.write(str(ss-12000)+"\n")
|
||||
f.close()
|
||||
|
||||
elif cmd == "Bounced":
|
||||
tags = args.get("tags", [])
|
||||
if "Online" in tags:
|
||||
data = args.get("data", {})
|
||||
if data["player"] != ctx.slot and data["player"] is not None:
|
||||
filename = f"FRISK" + str(data["player"]) + ".playerspot"
|
||||
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
|
||||
f.write(str(data["x"]) + str(data["y"]) + str(data["room"]) + str(
|
||||
data["spr"]) + str(data["frm"]))
|
||||
f.close()
|
||||
|
||||
|
||||
async def multi_watcher(ctx: UndertaleContext):
|
||||
while not ctx.exit_event.is_set():
|
||||
path = ctx.save_game_folder
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if "spots.mine" in file and "Online" in ctx.tags:
|
||||
with open(os.path.join(root, file), "r") as mine:
|
||||
this_x = mine.readline()
|
||||
this_y = mine.readline()
|
||||
this_room = mine.readline()
|
||||
this_sprite = mine.readline()
|
||||
this_frame = mine.readline()
|
||||
mine.close()
|
||||
message = [{"cmd": "Bounce", "tags": ["Online"],
|
||||
"data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room,
|
||||
"spr": this_sprite, "frm": this_frame}}]
|
||||
await ctx.send_msgs(message)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
async def game_watcher(ctx: UndertaleContext):
|
||||
while not ctx.exit_event.is_set():
|
||||
await ctx.update_death_link(ctx.deathlink_status)
|
||||
path = ctx.save_game_folder
|
||||
if ctx.syncing:
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if ".item" in file:
|
||||
os.remove(os.path.join(root, file))
|
||||
sync_msg = [{"cmd": "Sync"}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
ctx.syncing = False
|
||||
if ctx.got_deathlink:
|
||||
ctx.got_deathlink = False
|
||||
with open(os.path.join(ctx.save_game_folder, "WelcomeToTheDead.youDied"), "w") as f:
|
||||
f.close()
|
||||
sending = []
|
||||
victory = False
|
||||
found_routes = 0
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if "DontBeMad.mad" in file:
|
||||
os.remove(os.path.join(root, file))
|
||||
if "DeathLink" in ctx.tags:
|
||||
await ctx.send_death()
|
||||
if "scout" == file:
|
||||
sending = []
|
||||
try:
|
||||
with open(os.path.join(root, file), "r") as f:
|
||||
lines = f.readlines()
|
||||
for l in lines:
|
||||
if ctx.server_locations.__contains__(int(l)+12000):
|
||||
sending = sending + [int(l.rstrip('\n'))+12000]
|
||||
finally:
|
||||
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending,
|
||||
"create_as_hint": int(2)}])
|
||||
os.remove(os.path.join(root, file))
|
||||
if "check.spot" in file:
|
||||
sending = []
|
||||
try:
|
||||
with open(os.path.join(root, file), "r") as f:
|
||||
lines = f.readlines()
|
||||
for l in lines:
|
||||
sending = sending+[(int(l.rstrip('\n')))+12000]
|
||||
finally:
|
||||
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}])
|
||||
if "victory" in file and str(ctx.route) in file:
|
||||
victory = True
|
||||
if ".playerspot" in file and "Online" not in ctx.tags:
|
||||
os.remove(os.path.join(root, file))
|
||||
if "victory" in file:
|
||||
if str(ctx.route) == "all_routes":
|
||||
if "neutral" in file and ctx.completed_routes["neutral"] != 1:
|
||||
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone neutral",
|
||||
"default": 0, "want_reply": True, "operations": [{"operation": "max",
|
||||
"value": 1}]}])
|
||||
elif "pacifist" in file and ctx.completed_routes["pacifist"] != 1:
|
||||
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone pacifist",
|
||||
"default": 0, "want_reply": True, "operations": [{"operation": "max",
|
||||
"value": 1}]}])
|
||||
elif "genocide" in file and ctx.completed_routes["genocide"] != 1:
|
||||
await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone genocide",
|
||||
"default": 0, "want_reply": True, "operations": [{"operation": "max",
|
||||
"value": 1}]}])
|
||||
if str(ctx.route) == "all_routes":
|
||||
found_routes += ctx.completed_routes["neutral"]
|
||||
found_routes += ctx.completed_routes["pacifist"]
|
||||
found_routes += ctx.completed_routes["genocide"]
|
||||
if str(ctx.route) == "all_routes" and found_routes >= 3:
|
||||
victory = True
|
||||
ctx.locations_checked = sending
|
||||
if (not ctx.finished_game) and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
def main():
|
||||
Utils.init_logging("UndertaleClient", exception_logger="Client")
|
||||
|
||||
async def _main():
|
||||
ctx = UndertaleContext(None, None)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
asyncio.create_task(
|
||||
game_watcher(ctx), name="UndertaleProgressionWatcher")
|
||||
|
||||
asyncio.create_task(
|
||||
multi_watcher(ctx), name="UndertaleMultiplayerWatcher")
|
||||
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
await ctx.shutdown()
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(_main())
|
||||
colorama.deinit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = get_base_parser(description="Undertale Client, for text interfacing.")
|
||||
args = parser.parse_args()
|
||||
main()
|
||||
361
Utils.py
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import typing
|
||||
import builtins
|
||||
import os
|
||||
@@ -12,8 +13,10 @@ import io
|
||||
import collections
|
||||
import importlib
|
||||
import logging
|
||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
|
||||
|
||||
from argparse import Namespace
|
||||
from settings import Settings, get_settings
|
||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
|
||||
from yaml import load, load_all, dump, SafeLoader
|
||||
|
||||
try:
|
||||
@@ -37,8 +40,11 @@ class Version(typing.NamedTuple):
|
||||
minor: int
|
||||
build: int
|
||||
|
||||
def as_simple_string(self) -> str:
|
||||
return ".".join(str(item) for item in self)
|
||||
|
||||
__version__ = "0.3.9"
|
||||
|
||||
__version__ = "0.4.3"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
@@ -87,7 +93,10 @@ def is_frozen() -> bool:
|
||||
|
||||
|
||||
def local_path(*path: str) -> str:
|
||||
"""Returns path to a file in the local Archipelago installation or source."""
|
||||
"""
|
||||
Returns path to a file in the local Archipelago installation or source.
|
||||
This might be read-only and user_path should be used instead for ROMs, configuration, etc.
|
||||
"""
|
||||
if hasattr(local_path, 'cached_path'):
|
||||
pass
|
||||
elif is_frozen():
|
||||
@@ -131,17 +140,31 @@ def user_path(*path: str) -> str:
|
||||
user_path.cached_path = local_path()
|
||||
else:
|
||||
user_path.cached_path = home_path()
|
||||
# populate home from local - TODO: upgrade feature
|
||||
if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")):
|
||||
import shutil
|
||||
for dn in ("Players", "data/sprites"):
|
||||
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
|
||||
for fn in ("manifest.json", "host.yaml"):
|
||||
shutil.copy2(local_path(fn), user_path(fn))
|
||||
# populate home from local
|
||||
if user_path.cached_path != local_path():
|
||||
import filecmp
|
||||
if not os.path.exists(user_path("manifest.json")) or \
|
||||
not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True):
|
||||
import shutil
|
||||
for dn in ("Players", "data/sprites"):
|
||||
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
|
||||
for fn in ("manifest.json",):
|
||||
shutil.copy2(local_path(fn), user_path(fn))
|
||||
|
||||
return os.path.join(user_path.cached_path, *path)
|
||||
|
||||
|
||||
def cache_path(*path: str) -> str:
|
||||
"""Returns path to a file in the user's Archipelago cache directory."""
|
||||
if hasattr(cache_path, "cached_path"):
|
||||
pass
|
||||
else:
|
||||
import platformdirs
|
||||
cache_path.cached_path = platformdirs.user_cache_dir("Archipelago", False)
|
||||
|
||||
return os.path.join(cache_path.cached_path, *path)
|
||||
|
||||
|
||||
def output_path(*path: str) -> str:
|
||||
if hasattr(output_path, 'cached_path'):
|
||||
return os.path.join(output_path.cached_path, *path)
|
||||
@@ -220,142 +243,15 @@ def get_public_ipv6() -> str:
|
||||
return ip
|
||||
|
||||
|
||||
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
|
||||
OptionsType = Settings # TODO: remove ~2 versions after 0.4.1
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_default_options() -> OptionsType:
|
||||
# Refer to host.yaml for comments as to what all these options mean.
|
||||
options = {
|
||||
"general_options": {
|
||||
"output_path": "output",
|
||||
},
|
||||
"factorio_options": {
|
||||
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
|
||||
"filter_item_sends": False,
|
||||
"bridge_chat_out": True,
|
||||
},
|
||||
"sni_options": {
|
||||
"sni_path": "SNI",
|
||||
"snes_rom_start": True,
|
||||
},
|
||||
"sm_options": {
|
||||
"rom_file": "Super Metroid (JU).sfc",
|
||||
},
|
||||
"soe_options": {
|
||||
"rom_file": "Secret of Evermore (USA).sfc",
|
||||
},
|
||||
"lttp_options": {
|
||||
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
||||
},
|
||||
"server_options": {
|
||||
"host": None,
|
||||
"port": 38281,
|
||||
"password": None,
|
||||
"multidata": None,
|
||||
"savefile": None,
|
||||
"disable_save": False,
|
||||
"loglevel": "info",
|
||||
"server_password": None,
|
||||
"disable_item_cheat": False,
|
||||
"location_check_points": 1,
|
||||
"hint_cost": 10,
|
||||
"release_mode": "goal",
|
||||
"collect_mode": "disabled",
|
||||
"remaining_mode": "goal",
|
||||
"auto_shutdown": 0,
|
||||
"compatibility": 2,
|
||||
"log_network": 0
|
||||
},
|
||||
"generator": {
|
||||
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
|
||||
"player_files_path": "Players",
|
||||
"players": 0,
|
||||
"weights_file_path": "weights.yaml",
|
||||
"meta_file_path": "meta.yaml",
|
||||
"spoiler": 3,
|
||||
"glitch_triforce_room": 1,
|
||||
"race": 0,
|
||||
"plando_options": "bosses",
|
||||
},
|
||||
"minecraft_options": {
|
||||
"forge_directory": "Minecraft Forge server",
|
||||
"max_heap_size": "2G",
|
||||
"release_channel": "release"
|
||||
},
|
||||
"oot_options": {
|
||||
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
|
||||
"rom_start": True
|
||||
},
|
||||
"dkc3_options": {
|
||||
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
|
||||
},
|
||||
"smw_options": {
|
||||
"rom_file": "Super Mario World (USA).sfc",
|
||||
},
|
||||
"zillion_options": {
|
||||
"rom_file": "Zillion (UE) [!].sms",
|
||||
# RetroArch doesn't make it easy to launch a game from the command line.
|
||||
# You have to know the path to the emulator core library on the user's computer.
|
||||
"rom_start": "retroarch",
|
||||
},
|
||||
"pokemon_rb_options": {
|
||||
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
|
||||
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",
|
||||
"rom_start": True
|
||||
},
|
||||
"ffr_options": {
|
||||
"display_msgs": True,
|
||||
},
|
||||
"lufia2ac_options": {
|
||||
"rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc",
|
||||
},
|
||||
"tloz_options": {
|
||||
"rom_file": "Legend of Zelda, The (U) (PRG0) [!].nes",
|
||||
"rom_start": True,
|
||||
"display_msgs": True,
|
||||
},
|
||||
"wargroove_options": {
|
||||
"root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove"
|
||||
}
|
||||
}
|
||||
return options
|
||||
def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1
|
||||
return Settings(None)
|
||||
|
||||
|
||||
def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType:
|
||||
for key, value in src.items():
|
||||
new_keys = keys.copy()
|
||||
new_keys.append(key)
|
||||
option_name = '.'.join(new_keys)
|
||||
if key not in dest:
|
||||
dest[key] = value
|
||||
if filename.endswith("options.yaml"):
|
||||
logging.info(f"Warning: {filename} is missing {option_name}")
|
||||
elif isinstance(value, dict):
|
||||
if not isinstance(dest.get(key, None), dict):
|
||||
if filename.endswith("options.yaml"):
|
||||
logging.info(f"Warning: {filename} has {option_name}, but it is not a dictionary. overwriting.")
|
||||
dest[key] = value
|
||||
else:
|
||||
dest[key] = update_options(value, dest[key], filename, new_keys)
|
||||
return dest
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_options() -> OptionsType:
|
||||
filenames = ("options.yaml", "host.yaml")
|
||||
locations: typing.List[str] = []
|
||||
if os.path.join(os.getcwd()) != local_path():
|
||||
locations += filenames # use files from cwd only if it's not the local_path
|
||||
locations += [user_path(filename) for filename in filenames]
|
||||
|
||||
for location in locations:
|
||||
if os.path.exists(location):
|
||||
with open(location) as f:
|
||||
options = parse_yaml(f.read())
|
||||
return update_options(get_default_options(), options, location, list())
|
||||
|
||||
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
|
||||
get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported
|
||||
|
||||
|
||||
def persistent_store(category: str, key: typing.Any, value: typing.Any):
|
||||
@@ -385,11 +281,65 @@ def persistent_load() -> typing.Dict[str, dict]:
|
||||
return storage
|
||||
|
||||
|
||||
def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]:
|
||||
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
|
||||
def get_file_safe_name(name: str) -> str:
|
||||
return "".join(c for c in name if c not in '<>:"/\\|?*')
|
||||
|
||||
|
||||
def load_data_package_for_checksum(game: str, checksum: typing.Optional[str]) -> Dict[str, Any]:
|
||||
if checksum and game:
|
||||
if checksum != get_file_safe_name(checksum):
|
||||
raise ValueError(f"Bad symbols in checksum: {checksum}")
|
||||
path = cache_path("datapackage", get_file_safe_name(game), f"{checksum}.json")
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8-sig") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not load data package: {e}")
|
||||
|
||||
# fall back to old cache
|
||||
cache = persistent_load().get("datapackage", {}).get("games", {}).get(game, {})
|
||||
if cache.get("checksum") == checksum:
|
||||
return cache
|
||||
|
||||
# cache does not match
|
||||
return {}
|
||||
|
||||
|
||||
def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> None:
|
||||
checksum = data.get("checksum")
|
||||
if checksum and game:
|
||||
if checksum != get_file_safe_name(checksum):
|
||||
raise ValueError(f"Bad symbols in checksum: {checksum}")
|
||||
game_folder = cache_path("datapackage", get_file_safe_name(game))
|
||||
os.makedirs(game_folder, exist_ok=True)
|
||||
try:
|
||||
with open(os.path.join(game_folder, f"{checksum}.json"), "w", encoding="utf-8-sig") as f:
|
||||
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not store data package: {e}")
|
||||
|
||||
def get_default_adjuster_settings(game_name: str) -> Namespace:
|
||||
import LttPAdjuster
|
||||
adjuster_settings = Namespace()
|
||||
if game_name == LttPAdjuster.GAME_ALTTP:
|
||||
return LttPAdjuster.get_argparser().parse_known_args(args=[])[0]
|
||||
|
||||
return adjuster_settings
|
||||
|
||||
|
||||
def get_adjuster_settings_no_defaults(game_name: str) -> Namespace:
|
||||
return persistent_load().get("adjuster", {}).get(game_name, Namespace())
|
||||
|
||||
|
||||
def get_adjuster_settings(game_name: str) -> Namespace:
|
||||
adjuster_settings = get_adjuster_settings_no_defaults(game_name)
|
||||
default_settings = get_default_adjuster_settings(game_name)
|
||||
|
||||
# Fill in any arguments from the argparser that we haven't seen before
|
||||
return Namespace(**vars(adjuster_settings), **{k:v for k,v in vars(default_settings).items() if k not in vars(adjuster_settings)})
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_unique_identifier():
|
||||
uuid = persistent_load().get("client", {}).get("uuid", None)
|
||||
@@ -409,11 +359,13 @@ safe_builtins = frozenset((
|
||||
|
||||
|
||||
class RestrictedUnpickler(pickle.Unpickler):
|
||||
generic_properties_module: Optional[object]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
|
||||
self.options_module = importlib.import_module("Options")
|
||||
self.net_utils_module = importlib.import_module("NetUtils")
|
||||
self.generic_properties_module = importlib.import_module("worlds.generic")
|
||||
self.generic_properties_module = None
|
||||
|
||||
def find_class(self, module, name):
|
||||
if module == "builtins" and name in safe_builtins:
|
||||
@@ -423,6 +375,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
return getattr(self.net_utils_module, name)
|
||||
# Options and Plando are unpickled by WebHost -> Generate
|
||||
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
||||
if not self.generic_properties_module:
|
||||
self.generic_properties_module = importlib.import_module("worlds.generic")
|
||||
return getattr(self.generic_properties_module, name)
|
||||
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
|
||||
if module.lower().endswith("options"):
|
||||
@@ -442,6 +396,15 @@ def restricted_loads(s):
|
||||
return RestrictedUnpickler(io.BytesIO(s)).load()
|
||||
|
||||
|
||||
class ByValue:
|
||||
"""
|
||||
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
|
||||
See https://github.com/python/cpython/pull/26658 for why this exists.
|
||||
"""
|
||||
def __reduce_ex__(self, prot):
|
||||
return self.__class__, (self._value_, )
|
||||
|
||||
|
||||
class KeyedDefaultDict(collections.defaultdict):
|
||||
"""defaultdict variant that uses the missing key as argument to default_factory"""
|
||||
default_factory: typing.Callable[[typing.Any], typing.Any]
|
||||
@@ -474,6 +437,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
root_logger.removeHandler(handler)
|
||||
handler.close()
|
||||
root_logger.setLevel(loglevel)
|
||||
logging.getLogger("websockets").setLevel(loglevel) # make sure level is applied for websockets
|
||||
if "a" not in write_mode:
|
||||
name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}"
|
||||
file_handler = logging.FileHandler(
|
||||
@@ -597,7 +561,7 @@ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: ty
|
||||
)
|
||||
|
||||
|
||||
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \
|
||||
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[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
|
||||
@@ -608,11 +572,12 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
|
||||
kdialog = which("kdialog")
|
||||
if kdialog:
|
||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||
return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters)
|
||||
return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
|
||||
zenity = which("zenity")
|
||||
if zenity:
|
||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||
return run(zenity, f"--title={title}", "--file-selection", *z_filters)
|
||||
selection = (f"--filename={suggest}",) if suggest else ()
|
||||
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
||||
|
||||
# fall back to tk
|
||||
try:
|
||||
@@ -623,9 +588,47 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
|
||||
f'This attempt was made because open_filename was used for "{title}".')
|
||||
raise e
|
||||
else:
|
||||
root = tkinter.Tk()
|
||||
try:
|
||||
root = tkinter.Tk()
|
||||
except tkinter.TclError:
|
||||
return None # GUI not available. None is the same as a user clicking "cancel"
|
||||
root.withdraw()
|
||||
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes))
|
||||
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
|
||||
initialfile=suggest or None)
|
||||
|
||||
|
||||
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:
|
||||
# prefer native dialog
|
||||
from shutil import which
|
||||
kdialog = which("kdialog")
|
||||
if kdialog:
|
||||
return run(kdialog, f"--title={title}", "--getexistingdirectory",
|
||||
os.path.abspath(suggest) if suggest else ".")
|
||||
zenity = which("zenity")
|
||||
if zenity:
|
||||
z_filters = ("--directory",)
|
||||
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
|
||||
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
|
||||
|
||||
# fall back to tk
|
||||
try:
|
||||
import tkinter
|
||||
import tkinter.filedialog
|
||||
except Exception as e:
|
||||
logging.error('Could not load tkinter, which is likely not installed. '
|
||||
f'This attempt was made because open_filename was used for "{title}".')
|
||||
raise e
|
||||
else:
|
||||
try:
|
||||
root = tkinter.Tk()
|
||||
except tkinter.TclError:
|
||||
return None # GUI not available. None is the same as a user clicking "cancel"
|
||||
root.withdraw()
|
||||
return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None)
|
||||
|
||||
|
||||
def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
@@ -690,10 +693,10 @@ def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
|
||||
return buffer
|
||||
|
||||
|
||||
_faf_tasks: "Set[asyncio.Task[None]]" = set()
|
||||
_faf_tasks: "Set[asyncio.Task[typing.Any]]" = set()
|
||||
|
||||
|
||||
def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None:
|
||||
def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = None) -> None:
|
||||
"""
|
||||
Use this to start a task when you don't keep a reference to it or immediately await it,
|
||||
to prevent early garbage collection. "fire-and-forget"
|
||||
@@ -706,6 +709,60 @@ def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str]
|
||||
# ```
|
||||
# This implementation follows the pattern given in that documentation.
|
||||
|
||||
task = asyncio.create_task(co, name=name)
|
||||
task: asyncio.Task[typing.Any] = asyncio.create_task(co, name=name)
|
||||
_faf_tasks.add(task)
|
||||
task.add_done_callback(_faf_tasks.discard)
|
||||
|
||||
|
||||
def deprecate(message: str):
|
||||
if __debug__:
|
||||
raise Exception(message)
|
||||
import warnings
|
||||
warnings.warn(message)
|
||||
|
||||
def _extend_freeze_support() -> None:
|
||||
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
|
||||
# upstream issue: https://github.com/python/cpython/issues/76327
|
||||
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
|
||||
import multiprocessing
|
||||
import multiprocessing.spawn
|
||||
|
||||
def _freeze_support() -> None:
|
||||
"""Minimal freeze_support. Only apply this if frozen."""
|
||||
from subprocess import _args_from_interpreter_flags
|
||||
|
||||
# Prevent `spawn` from trying to read `__main__` in from the main script
|
||||
multiprocessing.process.ORIGINAL_DIR = None
|
||||
|
||||
# Handle the first process that MP will create
|
||||
if (
|
||||
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
|
||||
'from multiprocessing.semaphore_tracker import main', # Py<3.8
|
||||
'from multiprocessing.resource_tracker import main', # Py>=3.8
|
||||
'from multiprocessing.forkserver import main'
|
||||
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
|
||||
):
|
||||
exec(sys.argv[-1])
|
||||
sys.exit()
|
||||
|
||||
# Handle the second process that MP will create
|
||||
if multiprocessing.spawn.is_forking(sys.argv):
|
||||
kwargs = {}
|
||||
for arg in sys.argv[2:]:
|
||||
name, value = arg.split('=')
|
||||
if value == 'None':
|
||||
kwargs[name] = None
|
||||
else:
|
||||
kwargs[name] = int(value)
|
||||
multiprocessing.spawn.spawn_main(**kwargs)
|
||||
sys.exit()
|
||||
|
||||
if not is_windows and is_frozen():
|
||||
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
|
||||
|
||||
|
||||
def freeze_support() -> None:
|
||||
"""This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
|
||||
import multiprocessing
|
||||
_extend_freeze_support()
|
||||
multiprocessing.freeze_support()
|
||||
|
||||
22
WebHost.py
@@ -10,23 +10,19 @@ ModuleUpdate.update()
|
||||
|
||||
# in case app gets imported by something like gunicorn
|
||||
import Utils
|
||||
import settings
|
||||
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
|
||||
|
||||
from WebHostLib import register, app as raw_app
|
||||
from waitress import serve
|
||||
|
||||
from WebHostLib.models import db
|
||||
from WebHostLib.autolauncher import autohost, autogen
|
||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
from WebHostLib.options import create as create_options_files
|
||||
|
||||
settings.no_gui = True
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||
|
||||
|
||||
def get_app():
|
||||
from WebHostLib import register, cache, app as raw_app
|
||||
from WebHostLib.models import db
|
||||
|
||||
register()
|
||||
app = raw_app
|
||||
if os.path.exists(configpath) and not app.config["TESTING"]:
|
||||
@@ -38,6 +34,7 @@ def get_app():
|
||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
||||
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
|
||||
|
||||
cache.init_app(app)
|
||||
db.bind(**app.config["PONY"])
|
||||
db.generate_mapping(create_tables=True)
|
||||
return app
|
||||
@@ -72,6 +69,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
||||
with zipfile.ZipFile(zipfile_path) as zf:
|
||||
for zfile in zf.infolist():
|
||||
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
||||
zfile.filename = os.path.basename(zfile.filename)
|
||||
zf.extract(zfile, target_path)
|
||||
else:
|
||||
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
||||
@@ -117,6 +115,11 @@ if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
multiprocessing.set_start_method('spawn')
|
||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||
|
||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
from WebHostLib.autolauncher import autohost, autogen
|
||||
from WebHostLib.options import create as create_options_files
|
||||
|
||||
try:
|
||||
update_sprites_lttp()
|
||||
except Exception as e:
|
||||
@@ -133,4 +136,5 @@ if __name__ == "__main__":
|
||||
if app.config["DEBUG"]:
|
||||
app.run(debug=True, port=app.config["PORT"])
|
||||
else:
|
||||
from waitress import serve
|
||||
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
|
||||
|
||||
@@ -34,7 +34,7 @@ app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
||||
# if you want to deploy, make sure you have a non-guessable secret key
|
||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
||||
app.config["JOB_THRESHOLD"] = 2
|
||||
app.config["JOB_THRESHOLD"] = 1
|
||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||
app.config["JOB_TIME"] = 600
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
@@ -49,11 +49,11 @@ app.config["PONY"] = {
|
||||
'create_db': True
|
||||
}
|
||||
app.config["MAX_ROLL"] = 20
|
||||
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
|
||||
app.config["CACHE_TYPE"] = "SimpleCache"
|
||||
app.config["JSON_AS_ASCII"] = False
|
||||
app.config["HOST_ADDRESS"] = ""
|
||||
|
||||
cache = Cache(app)
|
||||
cache = Cache()
|
||||
Compress(app)
|
||||
|
||||
|
||||
|
||||
@@ -39,12 +39,21 @@ def get_datapackage():
|
||||
|
||||
@api_endpoints.route('/datapackage_version')
|
||||
@cache.cached()
|
||||
|
||||
def get_datapackage_versions():
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
from worlds import AutoWorldRegister
|
||||
|
||||
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||
return version_package
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage_checksum')
|
||||
@cache.cached()
|
||||
def get_datapackage_checksums():
|
||||
from worlds import network_data_package
|
||||
version_package = {
|
||||
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
|
||||
}
|
||||
return version_package
|
||||
|
||||
|
||||
from . import generate, user # trigger registration
|
||||
|
||||
@@ -2,7 +2,8 @@ import json
|
||||
import pickle
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request, session, url_for, Markup
|
||||
from flask import request, session, url_for
|
||||
from markupsafe import Markup
|
||||
from pony.orm import commit
|
||||
|
||||
from WebHostLib import app
|
||||
@@ -48,9 +49,8 @@ def generate_api():
|
||||
if len(options) > app.config["MAX_ROLL"]:
|
||||
return {"text": "Max size of multiworld exceeded",
|
||||
"detail": app.config["MAX_ROLL"]}, 409
|
||||
meta = get_meta(meta_options_source)
|
||||
meta["race"] = race
|
||||
results, gen_options = roll_options(options, meta["plando_options"])
|
||||
meta = get_meta(meta_options_source, race)
|
||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return {"text": str(results),
|
||||
"detail": results}, 400
|
||||
|
||||
@@ -3,8 +3,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
@@ -13,55 +11,7 @@ from datetime import timedelta, datetime
|
||||
from pony.orm import db_session, select, commit
|
||||
|
||||
from Utils import restricted_loads
|
||||
|
||||
|
||||
class CommonLocker():
|
||||
"""Uses a file lock to signal that something is already running"""
|
||||
lock_folder = "file_locks"
|
||||
|
||||
def __init__(self, lockname: str, folder=None):
|
||||
if folder:
|
||||
self.lock_folder = folder
|
||||
os.makedirs(self.lock_folder, exist_ok=True)
|
||||
self.lockname = lockname
|
||||
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
|
||||
|
||||
|
||||
class AlreadyRunningException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
if sys.platform == 'win32':
|
||||
class Locker(CommonLocker):
|
||||
def __enter__(self):
|
||||
try:
|
||||
if os.path.exists(self.lockfile):
|
||||
os.unlink(self.lockfile)
|
||||
self.fp = os.open(
|
||||
self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
def __exit__(self, _type, value, tb):
|
||||
fp = getattr(self, "fp", None)
|
||||
if fp:
|
||||
os.close(self.fp)
|
||||
os.unlink(self.lockfile)
|
||||
else: # unix
|
||||
import fcntl
|
||||
|
||||
|
||||
class Locker(CommonLocker):
|
||||
def __enter__(self):
|
||||
try:
|
||||
self.fp = open(self.lockfile, "wb")
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
def __exit__(self, _type, value, tb):
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
|
||||
self.fp.close()
|
||||
from .locker import Locker, AlreadyRunningException
|
||||
|
||||
|
||||
def launch_room(room: Room, config: dict):
|
||||
@@ -135,7 +85,7 @@ def autogen(config: dict):
|
||||
with Locker("autogen"):
|
||||
|
||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
|
||||
initargs=(config["PONY"],)) as generator_pool:
|
||||
initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
|
||||
with db_session:
|
||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import zipfile
|
||||
from typing import *
|
||||
|
||||
from flask import request, flash, redirect, url_for, render_template, Markup
|
||||
from flask import request, flash, redirect, url_for, render_template
|
||||
from markupsafe import Markup
|
||||
|
||||
from WebHostLib import app
|
||||
|
||||
@@ -23,8 +24,8 @@ def check():
|
||||
if 'file' not in request.files:
|
||||
flash('No file part')
|
||||
else:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
files = request.files.getlist('file')
|
||||
options = get_yaml_data(files)
|
||||
if isinstance(options, str):
|
||||
flash(options)
|
||||
else:
|
||||
@@ -38,29 +39,33 @@ def mysterycheck():
|
||||
return redirect(url_for("check"), 301)
|
||||
|
||||
|
||||
def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
|
||||
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
|
||||
options = {}
|
||||
# if user does not select file, browser also
|
||||
# submit an empty part without filename
|
||||
if file.filename == '':
|
||||
return 'No selected file'
|
||||
elif file and allowed_file(file.filename):
|
||||
if file.filename.endswith(".zip"):
|
||||
for file in files:
|
||||
# if user does not select file, browser also
|
||||
# submit an empty part without filename
|
||||
if file.filename == '':
|
||||
return 'No selected file'
|
||||
elif file.filename in options:
|
||||
return f'Conflicting files named {file.filename} submitted'
|
||||
elif file and allowed_file(file.filename):
|
||||
if file.filename.endswith(".zip"):
|
||||
|
||||
with zipfile.ZipFile(file, 'r') as zfile:
|
||||
infolist = zfile.infolist()
|
||||
with zipfile.ZipFile(file, 'r') as zfile:
|
||||
infolist = zfile.infolist()
|
||||
|
||||
if any(file.filename.endswith(".archipelago") for file in infolist):
|
||||
return Markup("Error: Your .zip file contains an .archipelago file. "
|
||||
'Did you mean to <a href="/uploads">host a game</a>?')
|
||||
if any(file.filename.endswith(".archipelago") for file in infolist):
|
||||
return Markup("Error: Your .zip file contains an .archipelago file. "
|
||||
'Did you mean to <a href="/uploads">host a game</a>?')
|
||||
|
||||
for file in infolist:
|
||||
if file.filename.endswith(banned_zip_contents):
|
||||
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted."
|
||||
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
|
||||
options[file.filename] = zfile.open(file, "r").read()
|
||||
else:
|
||||
options = {file.filename: file.read()}
|
||||
for file in infolist:
|
||||
if file.filename.endswith(banned_zip_contents):
|
||||
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
|
||||
"Your file was deleted."
|
||||
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
|
||||
options[file.filename] = zfile.open(file, "r").read()
|
||||
else:
|
||||
options[file.filename] = file.read()
|
||||
if not options:
|
||||
return "Did not find a .yaml file to process."
|
||||
return options
|
||||
@@ -90,7 +95,7 @@ def roll_options(options: Dict[str, Union[dict, str]],
|
||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||
plando_options=plando_options)
|
||||
except Exception as e:
|
||||
results[filename] = f"Failed to generate mystery in {filename}: {e}"
|
||||
results[filename] = f"Failed to generate options in {filename}: {e}"
|
||||
else:
|
||||
results[filename] = True
|
||||
return results, rolled_results
|
||||
|
||||
@@ -18,8 +18,9 @@ from pony.orm import commit, db_session, select
|
||||
import Utils
|
||||
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
||||
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
|
||||
from .models import Room, Command, db
|
||||
from Utils import restricted_loads, cache_argsless
|
||||
from .locker import Locker
|
||||
from .models import Command, GameDataPackage, Room, db
|
||||
|
||||
|
||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
@@ -92,7 +93,21 @@ class WebHostContext(Context):
|
||||
else:
|
||||
self.port = get_random_port()
|
||||
|
||||
return self._load(self.decompress(room.seed.multidata), True)
|
||||
multidata = self.decompress(room.seed.multidata)
|
||||
game_data_packages = {}
|
||||
for game in list(multidata.get("datapackage", {})):
|
||||
game_data = multidata["datapackage"][game]
|
||||
if "checksum" in game_data:
|
||||
if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
||||
# non-custom. remove from multidata
|
||||
# games package could be dropped from static data once all rooms embed data package
|
||||
del multidata["datapackage"][game]
|
||||
else:
|
||||
row = GameDataPackage.get(checksum=game_data["checksum"])
|
||||
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
||||
game_data_packages[game] = Utils.restricted_loads(row.data)
|
||||
|
||||
return self._load(multidata, game_data_packages, True)
|
||||
|
||||
@db_session
|
||||
def init_save(self, enabled: bool = True):
|
||||
@@ -149,19 +164,20 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
|
||||
db.generate_mapping(check_tables=False)
|
||||
|
||||
async def main():
|
||||
import gc
|
||||
|
||||
Utils.init_logging(str(room_id), write_mode="a")
|
||||
ctx = WebHostContext(static_server_data)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
||||
gc.collect() # free intermediate objects used during setup
|
||||
try:
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
|
||||
ping_interval=None, ssl=ssl_context)
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
|
||||
|
||||
await ctx.server
|
||||
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None,
|
||||
ping_interval=None, ssl=ssl_context)
|
||||
except OSError: # likely port in use
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
||||
|
||||
await ctx.server
|
||||
port = 0
|
||||
@@ -186,11 +202,15 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
|
||||
await ctx.shutdown_task
|
||||
logging.info("Shutting down")
|
||||
|
||||
from .autolauncher import Locker
|
||||
with Locker(room_id):
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except:
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
except Exception:
|
||||
with db_session:
|
||||
room = Room.get(id=room_id)
|
||||
room.last_port = -1
|
||||
|
||||
@@ -88,6 +88,8 @@ def download_slot_file(room_id, player_id: int):
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
|
||||
elif slot_data.game == "Dark Souls III":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
|
||||
elif slot_data.game == "Kingdom Hearts 2":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
|
||||
else:
|
||||
return "Game download not supported."
|
||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
|
||||
|
||||
@@ -6,7 +6,7 @@ import tempfile
|
||||
import zipfile
|
||||
import concurrent.futures
|
||||
from collections import Counter
|
||||
from typing import Dict, Optional, Any
|
||||
from typing import Dict, Optional, Any, Union, List
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
from pony.orm import commit, db_session
|
||||
@@ -22,7 +22,7 @@ from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
|
||||
from .upload import upload_zip_to_db
|
||||
|
||||
|
||||
def get_meta(options_source: dict) -> dict:
|
||||
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
|
||||
plando_options = {
|
||||
options_source.get("plando_bosses", ""),
|
||||
options_source.get("plando_items", ""),
|
||||
@@ -39,7 +39,21 @@ def get_meta(options_source: dict) -> dict:
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||
"server_password": options_source.get("server_password", None),
|
||||
}
|
||||
return {"server_options": server_options, "plando_options": list(plando_options)}
|
||||
generator_options = {
|
||||
"spoiler": int(options_source.get("spoiler", 0)),
|
||||
"race": race
|
||||
}
|
||||
|
||||
if race:
|
||||
server_options["item_cheat"] = False
|
||||
server_options["remaining_mode"] = "disabled"
|
||||
generator_options["spoiler"] = 0
|
||||
|
||||
return {
|
||||
"server_options": server_options,
|
||||
"plando_options": list(plando_options),
|
||||
"generator_options": generator_options,
|
||||
}
|
||||
|
||||
|
||||
@app.route('/generate', methods=['GET', 'POST'])
|
||||
@@ -50,18 +64,13 @@ def generate(race=False):
|
||||
if 'file' not in request.files:
|
||||
flash('No file part')
|
||||
else:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
files = request.files.getlist('file')
|
||||
options = get_yaml_data(files)
|
||||
if isinstance(options, str):
|
||||
flash(options)
|
||||
else:
|
||||
meta = get_meta(request.form)
|
||||
meta["race"] = race
|
||||
results, gen_options = roll_options(options, meta["plando_options"])
|
||||
|
||||
if race:
|
||||
meta["server_options"]["item_cheat"] = False
|
||||
meta["server_options"]["remaining_mode"] = "disabled"
|
||||
meta = get_meta(request.form, race)
|
||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
||||
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return render_template("checkResult.html", results=results)
|
||||
@@ -97,7 +106,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
||||
meta: Dict[str, Any] = {}
|
||||
|
||||
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
|
||||
race = meta.setdefault("race", False)
|
||||
race = meta.setdefault("generator_options", {}).setdefault("race", False)
|
||||
|
||||
def task():
|
||||
target = tempfile.TemporaryDirectory()
|
||||
@@ -114,13 +123,14 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
|
||||
erargs = parse_arguments(['--multi', str(playercount)])
|
||||
erargs.seed = seed
|
||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||
erargs.spoiler = 0 if race else 3
|
||||
erargs.spoiler = meta["generator_options"].get("spoiler", 0)
|
||||
erargs.race = race
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
erargs.skip_prog_balancing = False
|
||||
|
||||
name_counter = Counter()
|
||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||
|
||||
51
WebHostLib/locker.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
class CommonLocker:
|
||||
"""Uses a file lock to signal that something is already running"""
|
||||
lock_folder = "file_locks"
|
||||
|
||||
def __init__(self, lockname: str, folder=None):
|
||||
if folder:
|
||||
self.lock_folder = folder
|
||||
os.makedirs(self.lock_folder, exist_ok=True)
|
||||
self.lockname = lockname
|
||||
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
|
||||
|
||||
|
||||
class AlreadyRunningException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
if sys.platform == 'win32':
|
||||
class Locker(CommonLocker):
|
||||
def __enter__(self):
|
||||
try:
|
||||
if os.path.exists(self.lockfile):
|
||||
os.unlink(self.lockfile)
|
||||
self.fp = os.open(
|
||||
self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
def __exit__(self, _type, value, tb):
|
||||
fp = getattr(self, "fp", None)
|
||||
if fp:
|
||||
os.close(self.fp)
|
||||
os.unlink(self.lockfile)
|
||||
else: # unix
|
||||
import fcntl
|
||||
|
||||
|
||||
class Locker(CommonLocker):
|
||||
def __enter__(self):
|
||||
try:
|
||||
self.fp = open(self.lockfile, "wb")
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
def __exit__(self, _type, value, tb):
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
|
||||
self.fp.close()
|
||||
@@ -116,7 +116,11 @@ def display_log(room: UUID):
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if room.owner == session["_id"]:
|
||||
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||
file_path = os.path.join("logs", str(room.id) + ".txt")
|
||||
if os.path.exists(file_path):
|
||||
return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
|
||||
return "Log File does not exist."
|
||||
|
||||
return "Access Denied", 403
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class Slot(db.Entity):
|
||||
class Room(db.Entity):
|
||||
id = PrimaryKey(UUID, default=uuid4)
|
||||
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow())
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
|
||||
owner = Required(UUID, index=True)
|
||||
commands = Set('Command')
|
||||
seed = Required('Seed', index=True)
|
||||
@@ -38,7 +38,7 @@ class Seed(db.Entity):
|
||||
rooms = Set(Room)
|
||||
multidata = Required(bytes, lazy=True)
|
||||
owner = Required(UUID, index=True)
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow())
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
|
||||
slots = Set(Slot)
|
||||
spoiler = Optional(LongStr, lazy=True)
|
||||
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
|
||||
@@ -56,3 +56,8 @@ class Generation(db.Entity):
|
||||
options = Required(buffer, lazy=True)
|
||||
meta = Required(LongStr, default=lambda: "{\"race\": false}")
|
||||
state = Required(int, default=0, index=True)
|
||||
|
||||
|
||||
class GameDataPackage(db.Entity):
|
||||
checksum = PrimaryKey(str)
|
||||
data = Required(bytes)
|
||||
|
||||
@@ -11,35 +11,14 @@ from Utils import __version__, local_path
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||
"exclude_locations"}
|
||||
"exclude_locations", "priority_locations"}
|
||||
|
||||
|
||||
def create():
|
||||
target_folder = local_path("WebHostLib", "static", "generated")
|
||||
yaml_folder = os.path.join(target_folder, "configs")
|
||||
os.makedirs(yaml_folder, exist_ok=True)
|
||||
|
||||
for file in os.listdir(yaml_folder):
|
||||
full_path: str = os.path.join(yaml_folder, file)
|
||||
if os.path.isfile(full_path):
|
||||
os.unlink(full_path)
|
||||
|
||||
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
|
||||
data = {option.default: 50}
|
||||
for sub_option in ["random", "random-low", "random-high"]:
|
||||
if sub_option != option.default:
|
||||
data[sub_option] = 0
|
||||
|
||||
notes = {}
|
||||
for name, number in getattr(option, "special_range_names", {}).items():
|
||||
notes[name] = f"equivalent to {number}"
|
||||
if number in data:
|
||||
data[name] = data[number]
|
||||
del data[number]
|
||||
else:
|
||||
data[name] = 0
|
||||
|
||||
return data, notes
|
||||
Options.generate_yaml_templates(yaml_folder)
|
||||
|
||||
def get_html_doc(option_type: type(Options.Option)) -> str:
|
||||
if not option_type.__doc__:
|
||||
@@ -61,23 +40,11 @@ def create():
|
||||
**Options.per_game_common_options,
|
||||
**world.option_definitions
|
||||
}
|
||||
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
|
||||
file_data = f.read()
|
||||
res = Template(file_data).render(
|
||||
options=all_options,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||
dictify_range=dictify_range,
|
||||
)
|
||||
|
||||
del file_data
|
||||
|
||||
with open(os.path.join(target_folder, "configs", game_name + ".yaml"), "w", encoding="utf-8") as f:
|
||||
f.write(res)
|
||||
|
||||
# Generate JSON files for player-settings pages
|
||||
player_settings = {
|
||||
"baseOptions": {
|
||||
"description": "Generated by https://archipelago.gg/",
|
||||
"description": f"Generated by https://archipelago.gg/ for {game_name}",
|
||||
"game": game_name,
|
||||
"name": "Player",
|
||||
},
|
||||
@@ -88,7 +55,7 @@ def create():
|
||||
if option_name in handled_in_js:
|
||||
pass
|
||||
|
||||
elif option.options:
|
||||
elif issubclass(option, Options.Choice) or issubclass(option, Options.Toggle):
|
||||
game_options[option_name] = this_option = {
|
||||
"type": "select",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
@@ -98,15 +65,15 @@ def create():
|
||||
}
|
||||
|
||||
for sub_option_id, sub_option_name in option.name_lookup.items():
|
||||
this_option["options"].append({
|
||||
"name": option.get_option_name(sub_option_id),
|
||||
"value": sub_option_name,
|
||||
})
|
||||
|
||||
if sub_option_name != "random":
|
||||
this_option["options"].append({
|
||||
"name": option.get_option_name(sub_option_id),
|
||||
"value": sub_option_name,
|
||||
})
|
||||
if sub_option_id == option.default:
|
||||
this_option["defaultValue"] = sub_option_name
|
||||
|
||||
if option.default == "random":
|
||||
if not this_option["defaultValue"]:
|
||||
this_option["defaultValue"] = "random"
|
||||
|
||||
elif issubclass(option, Options.Range):
|
||||
@@ -126,27 +93,30 @@ def create():
|
||||
for key, val in option.special_range_names.items():
|
||||
game_options[option_name]["value_names"][key] = val
|
||||
|
||||
elif getattr(option, "verify_item_name", False):
|
||||
elif issubclass(option, Options.ItemSet):
|
||||
game_options[option_name] = {
|
||||
"type": "items-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": list(option.default)
|
||||
}
|
||||
|
||||
elif getattr(option, "verify_location_name", False):
|
||||
elif issubclass(option, Options.LocationSet):
|
||||
game_options[option_name] = {
|
||||
"type": "locations-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"defaultValue": list(option.default)
|
||||
}
|
||||
|
||||
elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet):
|
||||
elif issubclass(option, Options.VerifyKeys) and not issubclass(option, Options.OptionDict):
|
||||
if option.valid_keys:
|
||||
game_options[option_name] = {
|
||||
"type": "custom-list",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": get_html_doc(option),
|
||||
"options": list(option.valid_keys),
|
||||
"defaultValue": list(option.default) if hasattr(option, "default") else []
|
||||
}
|
||||
|
||||
else:
|
||||
@@ -160,6 +130,14 @@ def create():
|
||||
json.dump(player_settings, f, indent=2, separators=(',', ': '))
|
||||
|
||||
if not world.hidden and world.web.settings_page is True:
|
||||
# Add the random option to Choice, TextChoice, and Toggle settings
|
||||
for option in game_options.values():
|
||||
if option["type"] == "select":
|
||||
option["options"].append({"name": "Random", "value": "random"})
|
||||
|
||||
if not option["defaultValue"]:
|
||||
option["defaultValue"] = "random"
|
||||
|
||||
weighted_settings["baseOptions"]["game"][game_name] = 0
|
||||
weighted_settings["games"][game_name] = {}
|
||||
weighted_settings["games"][game_name]["gameSettings"] = game_options
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
flask>=2.2.3
|
||||
pony>=0.7.16
|
||||
pony>=0.7.16; python_version <= '3.10'
|
||||
pony @ https://github.com/Berserker66/pony/releases/download/v0.7.16/pony-0.7.16-py3-none-any.whl#0.7.16 ; python_version >= '3.11'
|
||||
waitress>=2.1.2
|
||||
Flask-Caching>=2.0.2
|
||||
Flask-Compress>=1.13
|
||||
Flask-Limiter>=3.3.0
|
||||
bokeh>=3.1.0
|
||||
Flask-Compress>=1.14
|
||||
Flask-Limiter>=3.5.0
|
||||
bokeh>=3.1.1; python_version <= '3.8'
|
||||
bokeh>=3.2.2; python_version >= '3.9'
|
||||
markupsafe>=2.1.3
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Mobile menu handling
|
||||
const menuButton = document.getElementById('base-header-mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('base-header-mobile-menu');
|
||||
|
||||
menuButton.addEventListener('click', (evt) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
if (!mobileMenu.style.display || mobileMenu.style.display === 'none') {
|
||||
return mobileMenu.style.display = 'flex';
|
||||
@@ -15,4 +17,24 @@ window.addEventListener('load', () => {
|
||||
window.addEventListener('resize', () => {
|
||||
mobileMenu.style.display = 'none';
|
||||
});
|
||||
|
||||
// Popover handling
|
||||
const popoverText = document.getElementById('base-header-popover-text');
|
||||
const popoverMenu = document.getElementById('base-header-popover-menu');
|
||||
|
||||
popoverText.addEventListener('click', (evt) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
if (!popoverMenu.style.display || popoverMenu.style.display === 'none') {
|
||||
return popoverMenu.style.display = 'flex';
|
||||
}
|
||||
|
||||
popoverMenu.style.display = 'none';
|
||||
});
|
||||
|
||||
document.body.addEventListener('click', () => {
|
||||
mobileMenu.style.display = 'none';
|
||||
popoverMenu.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,7 +148,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', setting);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, [select]));
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
|
||||
if (currentSettings[gameName][setting] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
select.disabled = true;
|
||||
@@ -185,7 +185,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
randomButton.classList.add('randomize-button');
|
||||
randomButton.setAttribute('data-key', setting);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, [range]));
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
|
||||
if (currentSettings[gameName][setting] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
range.disabled = true;
|
||||
@@ -269,7 +269,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
randomButton.setAttribute('data-key', setting);
|
||||
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
|
||||
randomButton.addEventListener('click', (event) => toggleRandomize(
|
||||
event, [specialRange, specialRangeSelect])
|
||||
event, specialRange, specialRangeSelect)
|
||||
);
|
||||
if (currentSettings[gameName][setting] === 'random') {
|
||||
randomButton.classList.add('active');
|
||||
@@ -294,23 +294,25 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
return table;
|
||||
};
|
||||
|
||||
const toggleRandomize = (event, inputElements) => {
|
||||
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
|
||||
const active = event.target.classList.contains('active');
|
||||
const randomButton = event.target;
|
||||
|
||||
if (active) {
|
||||
randomButton.classList.remove('active');
|
||||
for (const element of inputElements) {
|
||||
element.disabled = undefined;
|
||||
updateGameSetting(element);
|
||||
inputElement.disabled = undefined;
|
||||
if (optionalSelectElement) {
|
||||
optionalSelectElement.disabled = undefined;
|
||||
}
|
||||
} else {
|
||||
randomButton.classList.add('active');
|
||||
for (const element of inputElements) {
|
||||
element.disabled = true;
|
||||
updateGameSetting(randomButton);
|
||||
inputElement.disabled = true;
|
||||
if (optionalSelectElement) {
|
||||
optionalSelectElement.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
updateGameSetting(randomButton);
|
||||
};
|
||||
|
||||
const updateBaseSetting = (event) => {
|
||||
@@ -364,6 +366,7 @@ const generateGame = (raceMode = false) => {
|
||||
weights: { player: settings },
|
||||
presetData: { player: settings },
|
||||
playerCount: 1,
|
||||
spoiler: 3,
|
||||
race: raceMode ? '1' : '0',
|
||||
}).then((response) => {
|
||||
window.location.href = response.data.url;
|
||||
|
||||
83
WebHostLib/static/assets/supportedGames.js
Normal file
@@ -0,0 +1,83 @@
|
||||
window.addEventListener('load', () => {
|
||||
const gameHeaders = document.getElementsByClassName('collapse-toggle');
|
||||
Array.from(gameHeaders).forEach((header) => {
|
||||
const gameName = header.getAttribute('data-game');
|
||||
header.addEventListener('click', () => {
|
||||
const gameArrow = document.getElementById(`${gameName}-arrow`);
|
||||
const gameInfo = document.getElementById(gameName);
|
||||
if (gameInfo.classList.contains('collapsed')) {
|
||||
gameArrow.innerText = '▼';
|
||||
gameInfo.classList.remove('collapsed');
|
||||
} else {
|
||||
gameArrow.innerText = '▶';
|
||||
gameInfo.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle game filter input
|
||||
const gameSearch = document.getElementById('game-search');
|
||||
gameSearch.value = '';
|
||||
|
||||
gameSearch.addEventListener('input', (evt) => {
|
||||
if (!evt.target.value.trim()) {
|
||||
// If input is empty, display all collapsed games
|
||||
return Array.from(gameHeaders).forEach((header) => {
|
||||
header.style.display = null;
|
||||
const gameName = header.getAttribute('data-game');
|
||||
document.getElementById(`${gameName}-arrow`).innerText = '▶';
|
||||
document.getElementById(gameName).classList.add('collapsed');
|
||||
});
|
||||
}
|
||||
|
||||
// Loop over all the games
|
||||
Array.from(gameHeaders).forEach((header) => {
|
||||
const gameName = header.getAttribute('data-game');
|
||||
const gameArrow = document.getElementById(`${gameName}-arrow`);
|
||||
const gameInfo = document.getElementById(gameName);
|
||||
|
||||
// If the game name includes the search string, display the game. If not, hide it
|
||||
if (gameName.toLowerCase().includes(evt.target.value.toLowerCase())) {
|
||||
header.style.display = null;
|
||||
gameArrow.innerText = '▼';
|
||||
gameInfo.classList.remove('collapsed');
|
||||
} else {
|
||||
console.log(header);
|
||||
header.style.display = 'none';
|
||||
gameArrow.innerText = '▶';
|
||||
gameInfo.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('expand-all').addEventListener('click', expandAll);
|
||||
document.getElementById('collapse-all').addEventListener('click', collapseAll);
|
||||
});
|
||||
|
||||
const expandAll = () => {
|
||||
const gameHeaders = document.getElementsByClassName('collapse-toggle');
|
||||
// Loop over all the games
|
||||
Array.from(gameHeaders).forEach((header) => {
|
||||
const gameName = header.getAttribute('data-game');
|
||||
const gameArrow = document.getElementById(`${gameName}-arrow`);
|
||||
const gameInfo = document.getElementById(gameName);
|
||||
|
||||
if (header.style.display === 'none') { return; }
|
||||
gameArrow.innerText = '▼';
|
||||
gameInfo.classList.remove('collapsed');
|
||||
});
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
const gameHeaders = document.getElementsByClassName('collapse-toggle');
|
||||
// Loop over all the games
|
||||
Array.from(gameHeaders).forEach((header) => {
|
||||
const gameName = header.getAttribute('data-game');
|
||||
const gameArrow = document.getElementById(`${gameName}-arrow`);
|
||||
const gameInfo = document.getElementById(gameName);
|
||||
|
||||
if (header.style.display === 'none') { return; }
|
||||
gameArrow.innerText = '▶';
|
||||
gameInfo.classList.add('collapsed');
|
||||
});
|
||||
};
|
||||
@@ -14,6 +14,17 @@ const adjustTableHeight = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an integer number of seconds into a human readable HH:MM format
|
||||
* @param {Number} seconds
|
||||
* @returns {string}
|
||||
*/
|
||||
const secondsToHours = (seconds) => {
|
||||
let hours = Math.floor(seconds / 3600);
|
||||
let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const tables = $(".table").DataTable({
|
||||
paging: false,
|
||||
@@ -27,7 +38,18 @@ window.addEventListener('load', () => {
|
||||
stateLoadCallback: function(settings) {
|
||||
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
|
||||
},
|
||||
footerCallback: function(tfoot, data, start, end, display) {
|
||||
if (tfoot) {
|
||||
const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
|
||||
Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
|
||||
(activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
|
||||
}
|
||||
},
|
||||
columnDefs: [
|
||||
{
|
||||
targets: 'last-activity',
|
||||
name: 'lastActivity'
|
||||
},
|
||||
{
|
||||
targets: 'hours',
|
||||
render: function (data, type, row) {
|
||||
@@ -40,11 +62,7 @@ window.addEventListener('load', () => {
|
||||
if (data === "None")
|
||||
return data;
|
||||
|
||||
let hours = Math.floor(data / 3600);
|
||||
let minutes = Math.floor((data - (hours * 3600)) / 60);
|
||||
|
||||
if (minutes < 10) {minutes = "0"+minutes;}
|
||||
return hours+':'+minutes;
|
||||
return secondsToHours(data);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -114,11 +132,16 @@ window.addEventListener('load', () => {
|
||||
if (status === "success") {
|
||||
target.find(".table").each(function (i, new_table) {
|
||||
const new_trs = $(new_table).find("tbody>tr");
|
||||
const footer_tr = $(new_table).find("tfoot>tr");
|
||||
const old_table = tables.eq(i);
|
||||
const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
|
||||
const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
|
||||
old_table.clear();
|
||||
old_table.rows.add(new_trs).draw();
|
||||
if (footer_tr.length) {
|
||||
$(old_table.table).find("tfoot").html(footer_tr);
|
||||
}
|
||||
old_table.rows.add(new_trs);
|
||||
old_table.draw();
|
||||
$(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
|
||||
$(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
|
||||
});
|
||||
|
||||
@@ -78,8 +78,6 @@ const createDefaultSettings = (settingData) => {
|
||||
break;
|
||||
case 'range':
|
||||
case 'special_range':
|
||||
newSettings[game][gameSetting][setting.min] = 0;
|
||||
newSettings[game][gameSetting][setting.max] = 0;
|
||||
newSettings[game][gameSetting]['random'] = 0;
|
||||
newSettings[game][gameSetting]['random-low'] = 0;
|
||||
newSettings[game][gameSetting]['random-high'] = 0;
|
||||
@@ -93,7 +91,7 @@ const createDefaultSettings = (settingData) => {
|
||||
case 'items-list':
|
||||
case 'locations-list':
|
||||
case 'custom-list':
|
||||
newSettings[game][gameSetting] = [];
|
||||
newSettings[game][gameSetting] = setting.defaultValue;
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -103,6 +101,7 @@ const createDefaultSettings = (settingData) => {
|
||||
|
||||
newSettings[game].start_inventory = {};
|
||||
newSettings[game].exclude_locations = [];
|
||||
newSettings[game].priority_locations = [];
|
||||
newSettings[game].local_items = [];
|
||||
newSettings[game].non_local_items = [];
|
||||
newSettings[game].start_hints = [];
|
||||
@@ -138,30 +137,39 @@ const buildUI = (settingData) => {
|
||||
expandButton.classList.add('invisible');
|
||||
gameDiv.appendChild(expandButton);
|
||||
|
||||
const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings);
|
||||
settingData.games[game].gameItems.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0)));
|
||||
settingData.games[game].gameLocations.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0)));
|
||||
|
||||
const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings,
|
||||
settingData.games[game].gameItems, settingData.games[game].gameLocations);
|
||||
gameDiv.appendChild(weightedSettingsDiv);
|
||||
|
||||
const itemsDiv = buildItemsDiv(game, settingData.games[game].gameItems);
|
||||
gameDiv.appendChild(itemsDiv);
|
||||
const itemPoolDiv = buildItemsDiv(game, settingData.games[game].gameItems);
|
||||
gameDiv.appendChild(itemPoolDiv);
|
||||
|
||||
const hintsDiv = buildHintsDiv(game, settingData.games[game].gameItems, settingData.games[game].gameLocations);
|
||||
gameDiv.appendChild(hintsDiv);
|
||||
|
||||
const locationsDiv = buildLocationsDiv(game, settingData.games[game].gameLocations);
|
||||
gameDiv.appendChild(locationsDiv);
|
||||
|
||||
gamesWrapper.appendChild(gameDiv);
|
||||
|
||||
collapseButton.addEventListener('click', () => {
|
||||
collapseButton.classList.add('invisible');
|
||||
weightedSettingsDiv.classList.add('invisible');
|
||||
itemsDiv.classList.add('invisible');
|
||||
itemPoolDiv.classList.add('invisible');
|
||||
hintsDiv.classList.add('invisible');
|
||||
locationsDiv.classList.add('invisible');
|
||||
expandButton.classList.remove('invisible');
|
||||
});
|
||||
|
||||
expandButton.addEventListener('click', () => {
|
||||
collapseButton.classList.remove('invisible');
|
||||
weightedSettingsDiv.classList.remove('invisible');
|
||||
itemsDiv.classList.remove('invisible');
|
||||
itemPoolDiv.classList.remove('invisible');
|
||||
hintsDiv.classList.remove('invisible');
|
||||
locationsDiv.classList.remove('invisible');
|
||||
expandButton.classList.add('invisible');
|
||||
});
|
||||
});
|
||||
@@ -228,7 +236,7 @@ const buildGameChoice = (games) => {
|
||||
gameChoiceDiv.appendChild(table);
|
||||
};
|
||||
|
||||
const buildWeightedSettingsDiv = (game, settings) => {
|
||||
const buildWeightedSettingsDiv = (game, settings, gameItems, gameLocations) => {
|
||||
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
|
||||
const settingsWrapper = document.createElement('div');
|
||||
settingsWrapper.classList.add('settings-wrapper');
|
||||
@@ -270,7 +278,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
range.setAttribute('data-type', setting.type);
|
||||
range.setAttribute('min', 0);
|
||||
range.setAttribute('max', 50);
|
||||
range.addEventListener('change', updateGameSetting);
|
||||
range.addEventListener('change', updateRangeSetting);
|
||||
range.value = currentSettings[game][settingName][option.value];
|
||||
tdMiddle.appendChild(range);
|
||||
tr.appendChild(tdMiddle);
|
||||
@@ -296,33 +304,33 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
if (((setting.max - setting.min) + 1) < 11) {
|
||||
for (let i=setting.min; i <= setting.max; ++i) {
|
||||
const tr = document.createElement('tr');
|
||||
const tdLeft = document.createElement('td');
|
||||
tdLeft.classList.add('td-left');
|
||||
tdLeft.innerText = i;
|
||||
tr.appendChild(tdLeft);
|
||||
const tdLeft = document.createElement('td');
|
||||
tdLeft.classList.add('td-left');
|
||||
tdLeft.innerText = i;
|
||||
tr.appendChild(tdLeft);
|
||||
|
||||
const tdMiddle = document.createElement('td');
|
||||
tdMiddle.classList.add('td-middle');
|
||||
const range = document.createElement('input');
|
||||
range.setAttribute('type', 'range');
|
||||
range.setAttribute('id', `${game}-${settingName}-${i}-range`);
|
||||
range.setAttribute('data-game', game);
|
||||
range.setAttribute('data-setting', settingName);
|
||||
range.setAttribute('data-option', i);
|
||||
range.setAttribute('min', 0);
|
||||
range.setAttribute('max', 50);
|
||||
range.addEventListener('change', updateGameSetting);
|
||||
range.value = currentSettings[game][settingName][i];
|
||||
tdMiddle.appendChild(range);
|
||||
tr.appendChild(tdMiddle);
|
||||
const tdMiddle = document.createElement('td');
|
||||
tdMiddle.classList.add('td-middle');
|
||||
const range = document.createElement('input');
|
||||
range.setAttribute('type', 'range');
|
||||
range.setAttribute('id', `${game}-${settingName}-${i}-range`);
|
||||
range.setAttribute('data-game', game);
|
||||
range.setAttribute('data-setting', settingName);
|
||||
range.setAttribute('data-option', i);
|
||||
range.setAttribute('min', 0);
|
||||
range.setAttribute('max', 50);
|
||||
range.addEventListener('change', updateRangeSetting);
|
||||
range.value = currentSettings[game][settingName][i] || 0;
|
||||
tdMiddle.appendChild(range);
|
||||
tr.appendChild(tdMiddle);
|
||||
|
||||
const tdRight = document.createElement('td');
|
||||
tdRight.setAttribute('id', `${game}-${settingName}-${i}`)
|
||||
tdRight.classList.add('td-right');
|
||||
tdRight.innerText = range.value;
|
||||
tr.appendChild(tdRight);
|
||||
const tdRight = document.createElement('td');
|
||||
tdRight.setAttribute('id', `${game}-${settingName}-${i}`)
|
||||
tdRight.classList.add('td-right');
|
||||
tdRight.innerText = range.value;
|
||||
tr.appendChild(tdRight);
|
||||
|
||||
rangeTbody.appendChild(tr);
|
||||
rangeTbody.appendChild(tr);
|
||||
}
|
||||
} else {
|
||||
const hintText = document.createElement('p');
|
||||
@@ -379,7 +387,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
range.setAttribute('data-option', option);
|
||||
range.setAttribute('min', 0);
|
||||
range.setAttribute('max', 50);
|
||||
range.addEventListener('change', updateGameSetting);
|
||||
range.addEventListener('change', updateRangeSetting);
|
||||
range.value = currentSettings[game][settingName][parseInt(option, 10)];
|
||||
tdMiddle.appendChild(range);
|
||||
tr.appendChild(tdMiddle);
|
||||
@@ -430,7 +438,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
range.setAttribute('data-option', option);
|
||||
range.setAttribute('min', 0);
|
||||
range.setAttribute('max', 50);
|
||||
range.addEventListener('change', updateGameSetting);
|
||||
range.addEventListener('change', updateRangeSetting);
|
||||
range.value = currentSettings[game][settingName][parseInt(option, 10)];
|
||||
tdMiddle.appendChild(range);
|
||||
tr.appendChild(tdMiddle);
|
||||
@@ -464,7 +472,17 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
const tr = document.createElement('tr');
|
||||
const tdLeft = document.createElement('td');
|
||||
tdLeft.classList.add('td-left');
|
||||
tdLeft.innerText = option;
|
||||
switch(option){
|
||||
case 'random':
|
||||
tdLeft.innerText = 'Random';
|
||||
break;
|
||||
case 'random-low':
|
||||
tdLeft.innerText = "Random (Low)";
|
||||
break;
|
||||
case 'random-high':
|
||||
tdLeft.innerText = "Random (High)";
|
||||
break;
|
||||
}
|
||||
tr.appendChild(tdLeft);
|
||||
|
||||
const tdMiddle = document.createElement('td');
|
||||
@@ -477,7 +495,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
range.setAttribute('data-option', option);
|
||||
range.setAttribute('min', 0);
|
||||
range.setAttribute('max', 50);
|
||||
range.addEventListener('change', updateGameSetting);
|
||||
range.addEventListener('change', updateRangeSetting);
|
||||
range.value = currentSettings[game][settingName][option];
|
||||
tdMiddle.appendChild(range);
|
||||
tr.appendChild(tdMiddle);
|
||||
@@ -495,15 +513,108 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
break;
|
||||
|
||||
case 'items-list':
|
||||
// TODO
|
||||
const itemsList = document.createElement('div');
|
||||
itemsList.classList.add('simple-list');
|
||||
|
||||
Object.values(gameItems).forEach((item) => {
|
||||
const itemRow = document.createElement('div');
|
||||
itemRow.classList.add('list-row');
|
||||
|
||||
const itemLabel = document.createElement('label');
|
||||
itemLabel.setAttribute('for', `${game}-${settingName}-${item}`)
|
||||
|
||||
const itemCheckbox = document.createElement('input');
|
||||
itemCheckbox.setAttribute('id', `${game}-${settingName}-${item}`);
|
||||
itemCheckbox.setAttribute('type', 'checkbox');
|
||||
itemCheckbox.setAttribute('data-game', game);
|
||||
itemCheckbox.setAttribute('data-setting', settingName);
|
||||
itemCheckbox.setAttribute('data-option', item.toString());
|
||||
itemCheckbox.addEventListener('change', updateListSetting);
|
||||
if (currentSettings[game][settingName].includes(item)) {
|
||||
itemCheckbox.setAttribute('checked', '1');
|
||||
}
|
||||
|
||||
const itemName = document.createElement('span');
|
||||
itemName.innerText = item.toString();
|
||||
|
||||
itemLabel.appendChild(itemCheckbox);
|
||||
itemLabel.appendChild(itemName);
|
||||
|
||||
itemRow.appendChild(itemLabel);
|
||||
itemsList.appendChild((itemRow));
|
||||
});
|
||||
|
||||
settingWrapper.appendChild(itemsList);
|
||||
break;
|
||||
|
||||
case 'locations-list':
|
||||
// TODO
|
||||
const locationsList = document.createElement('div');
|
||||
locationsList.classList.add('simple-list');
|
||||
|
||||
Object.values(gameLocations).forEach((location) => {
|
||||
const locationRow = document.createElement('div');
|
||||
locationRow.classList.add('list-row');
|
||||
|
||||
const locationLabel = document.createElement('label');
|
||||
locationLabel.setAttribute('for', `${game}-${settingName}-${location}`)
|
||||
|
||||
const locationCheckbox = document.createElement('input');
|
||||
locationCheckbox.setAttribute('id', `${game}-${settingName}-${location}`);
|
||||
locationCheckbox.setAttribute('type', 'checkbox');
|
||||
locationCheckbox.setAttribute('data-game', game);
|
||||
locationCheckbox.setAttribute('data-setting', settingName);
|
||||
locationCheckbox.setAttribute('data-option', location.toString());
|
||||
locationCheckbox.addEventListener('change', updateListSetting);
|
||||
if (currentSettings[game][settingName].includes(location)) {
|
||||
locationCheckbox.setAttribute('checked', '1');
|
||||
}
|
||||
|
||||
const locationName = document.createElement('span');
|
||||
locationName.innerText = location.toString();
|
||||
|
||||
locationLabel.appendChild(locationCheckbox);
|
||||
locationLabel.appendChild(locationName);
|
||||
|
||||
locationRow.appendChild(locationLabel);
|
||||
locationsList.appendChild((locationRow));
|
||||
});
|
||||
|
||||
settingWrapper.appendChild(locationsList);
|
||||
break;
|
||||
|
||||
case 'custom-list':
|
||||
// TODO
|
||||
const customList = document.createElement('div');
|
||||
customList.classList.add('simple-list');
|
||||
|
||||
Object.values(settings[settingName].options).forEach((listItem) => {
|
||||
const customListRow = document.createElement('div');
|
||||
customListRow.classList.add('list-row');
|
||||
|
||||
const customItemLabel = document.createElement('label');
|
||||
customItemLabel.setAttribute('for', `${game}-${settingName}-${listItem}`)
|
||||
|
||||
const customItemCheckbox = document.createElement('input');
|
||||
customItemCheckbox.setAttribute('id', `${game}-${settingName}-${listItem}`);
|
||||
customItemCheckbox.setAttribute('type', 'checkbox');
|
||||
customItemCheckbox.setAttribute('data-game', game);
|
||||
customItemCheckbox.setAttribute('data-setting', settingName);
|
||||
customItemCheckbox.setAttribute('data-option', listItem.toString());
|
||||
customItemCheckbox.addEventListener('change', updateListSetting);
|
||||
if (currentSettings[game][settingName].includes(listItem)) {
|
||||
customItemCheckbox.setAttribute('checked', '1');
|
||||
}
|
||||
|
||||
const customItemName = document.createElement('span');
|
||||
customItemName.innerText = listItem.toString();
|
||||
|
||||
customItemLabel.appendChild(customItemCheckbox);
|
||||
customItemLabel.appendChild(customItemName);
|
||||
|
||||
customListRow.appendChild(customItemLabel);
|
||||
customList.appendChild((customListRow));
|
||||
});
|
||||
|
||||
settingWrapper.appendChild(customList);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -729,21 +840,22 @@ const buildHintsDiv = (game, items, locations) => {
|
||||
const hintsDescription = document.createElement('p');
|
||||
hintsDescription.classList.add('setting-description');
|
||||
hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' +
|
||||
' items are, or what those locations contain. Excluded locations will not contain progression items.';
|
||||
' items are, or what those locations contain.';
|
||||
hintsDiv.appendChild(hintsDescription);
|
||||
|
||||
const itemHintsContainer = document.createElement('div');
|
||||
itemHintsContainer.classList.add('hints-container');
|
||||
|
||||
// Item Hints
|
||||
const itemHintsWrapper = document.createElement('div');
|
||||
itemHintsWrapper.classList.add('hints-wrapper');
|
||||
itemHintsWrapper.innerText = 'Starting Item Hints';
|
||||
|
||||
const itemHintsDiv = document.createElement('div');
|
||||
itemHintsDiv.classList.add('item-container');
|
||||
itemHintsDiv.classList.add('simple-list');
|
||||
items.forEach((item) => {
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.classList.add('hint-div');
|
||||
const itemRow = document.createElement('div');
|
||||
itemRow.classList.add('list-row');
|
||||
|
||||
const itemLabel = document.createElement('label');
|
||||
itemLabel.setAttribute('for', `${game}-start_hints-${item}`);
|
||||
@@ -757,29 +869,30 @@ const buildHintsDiv = (game, items, locations) => {
|
||||
if (currentSettings[game].start_hints.includes(item)) {
|
||||
itemCheckbox.setAttribute('checked', 'true');
|
||||
}
|
||||
itemCheckbox.addEventListener('change', hintChangeHandler);
|
||||
itemCheckbox.addEventListener('change', updateListSetting);
|
||||
itemLabel.appendChild(itemCheckbox);
|
||||
|
||||
const itemName = document.createElement('span');
|
||||
itemName.innerText = item;
|
||||
itemLabel.appendChild(itemName);
|
||||
|
||||
itemDiv.appendChild(itemLabel);
|
||||
itemHintsDiv.appendChild(itemDiv);
|
||||
itemRow.appendChild(itemLabel);
|
||||
itemHintsDiv.appendChild(itemRow);
|
||||
});
|
||||
|
||||
itemHintsWrapper.appendChild(itemHintsDiv);
|
||||
itemHintsContainer.appendChild(itemHintsWrapper);
|
||||
|
||||
// Starting Location Hints
|
||||
const locationHintsWrapper = document.createElement('div');
|
||||
locationHintsWrapper.classList.add('hints-wrapper');
|
||||
locationHintsWrapper.innerText = 'Starting Location Hints';
|
||||
|
||||
const locationHintsDiv = document.createElement('div');
|
||||
locationHintsDiv.classList.add('item-container');
|
||||
locationHintsDiv.classList.add('simple-list');
|
||||
locations.forEach((location) => {
|
||||
const locationDiv = document.createElement('div');
|
||||
locationDiv.classList.add('hint-div');
|
||||
const locationRow = document.createElement('div');
|
||||
locationRow.classList.add('list-row');
|
||||
|
||||
const locationLabel = document.createElement('label');
|
||||
locationLabel.setAttribute('for', `${game}-start_location_hints-${location}`);
|
||||
@@ -793,29 +906,89 @@ const buildHintsDiv = (game, items, locations) => {
|
||||
if (currentSettings[game].start_location_hints.includes(location)) {
|
||||
locationCheckbox.setAttribute('checked', '1');
|
||||
}
|
||||
locationCheckbox.addEventListener('change', hintChangeHandler);
|
||||
locationCheckbox.addEventListener('change', updateListSetting);
|
||||
locationLabel.appendChild(locationCheckbox);
|
||||
|
||||
const locationName = document.createElement('span');
|
||||
locationName.innerText = location;
|
||||
locationLabel.appendChild(locationName);
|
||||
|
||||
locationDiv.appendChild(locationLabel);
|
||||
locationHintsDiv.appendChild(locationDiv);
|
||||
locationRow.appendChild(locationLabel);
|
||||
locationHintsDiv.appendChild(locationRow);
|
||||
});
|
||||
|
||||
locationHintsWrapper.appendChild(locationHintsDiv);
|
||||
itemHintsContainer.appendChild(locationHintsWrapper);
|
||||
|
||||
hintsDiv.appendChild(itemHintsContainer);
|
||||
return hintsDiv;
|
||||
};
|
||||
|
||||
const buildLocationsDiv = (game, locations) => {
|
||||
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
|
||||
locations.sort(); // Sort alphabetical, in-place
|
||||
|
||||
const locationsDiv = document.createElement('div');
|
||||
locationsDiv.classList.add('locations-div');
|
||||
const locationsHeader = document.createElement('h3');
|
||||
locationsHeader.innerText = 'Priority & Exclusion Locations';
|
||||
locationsDiv.appendChild(locationsHeader);
|
||||
const locationsDescription = document.createElement('p');
|
||||
locationsDescription.classList.add('setting-description');
|
||||
locationsDescription.innerText = 'Priority locations guarantee a progression item will be placed there while ' +
|
||||
'excluded locations will not contain progression or useful items.';
|
||||
locationsDiv.appendChild(locationsDescription);
|
||||
|
||||
const locationsContainer = document.createElement('div');
|
||||
locationsContainer.classList.add('locations-container');
|
||||
|
||||
// Priority Locations
|
||||
const priorityLocationsWrapper = document.createElement('div');
|
||||
priorityLocationsWrapper.classList.add('locations-wrapper');
|
||||
priorityLocationsWrapper.innerText = 'Priority Locations';
|
||||
|
||||
const priorityLocationsDiv = document.createElement('div');
|
||||
priorityLocationsDiv.classList.add('simple-list');
|
||||
locations.forEach((location) => {
|
||||
const locationRow = document.createElement('div');
|
||||
locationRow.classList.add('list-row');
|
||||
|
||||
const locationLabel = document.createElement('label');
|
||||
locationLabel.setAttribute('for', `${game}-priority_locations-${location}`);
|
||||
|
||||
const locationCheckbox = document.createElement('input');
|
||||
locationCheckbox.setAttribute('type', 'checkbox');
|
||||
locationCheckbox.setAttribute('id', `${game}-priority_locations-${location}`);
|
||||
locationCheckbox.setAttribute('data-game', game);
|
||||
locationCheckbox.setAttribute('data-setting', 'priority_locations');
|
||||
locationCheckbox.setAttribute('data-option', location);
|
||||
if (currentSettings[game].priority_locations.includes(location)) {
|
||||
locationCheckbox.setAttribute('checked', '1');
|
||||
}
|
||||
locationCheckbox.addEventListener('change', updateListSetting);
|
||||
locationLabel.appendChild(locationCheckbox);
|
||||
|
||||
const locationName = document.createElement('span');
|
||||
locationName.innerText = location;
|
||||
locationLabel.appendChild(locationName);
|
||||
|
||||
locationRow.appendChild(locationLabel);
|
||||
priorityLocationsDiv.appendChild(locationRow);
|
||||
});
|
||||
|
||||
priorityLocationsWrapper.appendChild(priorityLocationsDiv);
|
||||
locationsContainer.appendChild(priorityLocationsWrapper);
|
||||
|
||||
// Exclude Locations
|
||||
const excludeLocationsWrapper = document.createElement('div');
|
||||
excludeLocationsWrapper.classList.add('hints-wrapper');
|
||||
excludeLocationsWrapper.classList.add('locations-wrapper');
|
||||
excludeLocationsWrapper.innerText = 'Exclude Locations';
|
||||
|
||||
const excludeLocationsDiv = document.createElement('div');
|
||||
excludeLocationsDiv.classList.add('item-container');
|
||||
excludeLocationsDiv.classList.add('simple-list');
|
||||
locations.forEach((location) => {
|
||||
const locationDiv = document.createElement('div');
|
||||
locationDiv.classList.add('hint-div');
|
||||
const locationRow = document.createElement('div');
|
||||
locationRow.classList.add('list-row');
|
||||
|
||||
const locationLabel = document.createElement('label');
|
||||
locationLabel.setAttribute('for', `${game}-exclude_locations-${location}`);
|
||||
@@ -829,40 +1002,22 @@ const buildHintsDiv = (game, items, locations) => {
|
||||
if (currentSettings[game].exclude_locations.includes(location)) {
|
||||
locationCheckbox.setAttribute('checked', '1');
|
||||
}
|
||||
locationCheckbox.addEventListener('change', hintChangeHandler);
|
||||
locationCheckbox.addEventListener('change', updateListSetting);
|
||||
locationLabel.appendChild(locationCheckbox);
|
||||
|
||||
const locationName = document.createElement('span');
|
||||
locationName.innerText = location;
|
||||
locationLabel.appendChild(locationName);
|
||||
|
||||
locationDiv.appendChild(locationLabel);
|
||||
excludeLocationsDiv.appendChild(locationDiv);
|
||||
locationRow.appendChild(locationLabel);
|
||||
excludeLocationsDiv.appendChild(locationRow);
|
||||
});
|
||||
|
||||
excludeLocationsWrapper.appendChild(excludeLocationsDiv);
|
||||
itemHintsContainer.appendChild(excludeLocationsWrapper);
|
||||
locationsContainer.appendChild(excludeLocationsWrapper);
|
||||
|
||||
hintsDiv.appendChild(itemHintsContainer);
|
||||
return hintsDiv;
|
||||
};
|
||||
|
||||
const hintChangeHandler = (evt) => {
|
||||
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
|
||||
const game = evt.target.getAttribute('data-game');
|
||||
const setting = evt.target.getAttribute('data-setting');
|
||||
const option = evt.target.getAttribute('data-option');
|
||||
|
||||
if (evt.target.checked) {
|
||||
if (!currentSettings[game][setting].includes(option)) {
|
||||
currentSettings[game][setting].push(option);
|
||||
}
|
||||
} else {
|
||||
if (currentSettings[game][setting].includes(option)) {
|
||||
currentSettings[game][setting].splice(currentSettings[game][setting].indexOf(option), 1);
|
||||
}
|
||||
}
|
||||
localStorage.setItem('weighted-settings', JSON.stringify(currentSettings));
|
||||
locationsDiv.appendChild(locationsContainer);
|
||||
return locationsDiv;
|
||||
};
|
||||
|
||||
const updateVisibleGames = () => {
|
||||
@@ -908,13 +1063,12 @@ const updateBaseSetting = (event) => {
|
||||
localStorage.setItem('weighted-settings', JSON.stringify(settings));
|
||||
};
|
||||
|
||||
const updateGameSetting = (evt) => {
|
||||
const updateRangeSetting = (evt) => {
|
||||
const options = JSON.parse(localStorage.getItem('weighted-settings'));
|
||||
const game = evt.target.getAttribute('data-game');
|
||||
const setting = evt.target.getAttribute('data-setting');
|
||||
const option = evt.target.getAttribute('data-option');
|
||||
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
|
||||
console.log(event);
|
||||
if (evt.action && evt.action === 'rangeDelete') {
|
||||
delete options[game][setting][option];
|
||||
} else {
|
||||
@@ -923,6 +1077,26 @@ const updateGameSetting = (evt) => {
|
||||
localStorage.setItem('weighted-settings', JSON.stringify(options));
|
||||
};
|
||||
|
||||
const updateListSetting = (evt) => {
|
||||
const options = JSON.parse(localStorage.getItem('weighted-settings'));
|
||||
const game = evt.target.getAttribute('data-game');
|
||||
const setting = evt.target.getAttribute('data-setting');
|
||||
const option = evt.target.getAttribute('data-option');
|
||||
|
||||
if (evt.target.checked) {
|
||||
// If the option is to be enabled and it is already enabled, do nothing
|
||||
if (options[game][setting].includes(option)) { return; }
|
||||
|
||||
options[game][setting].push(option);
|
||||
} else {
|
||||
// If the option is to be disabled and it is already disabled, do nothing
|
||||
if (!options[game][setting].includes(option)) { return; }
|
||||
|
||||
options[game][setting].splice(options[game][setting].indexOf(option), 1);
|
||||
}
|
||||
localStorage.setItem('weighted-settings', JSON.stringify(options));
|
||||
};
|
||||
|
||||
const updateItemSetting = (evt) => {
|
||||
const options = JSON.parse(localStorage.getItem('weighted-settings'));
|
||||
const game = evt.target.getAttribute('data-game');
|
||||
@@ -962,8 +1136,8 @@ const validateSettings = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any disabled options
|
||||
Object.keys(settings[game]).forEach((setting) => {
|
||||
// Remove any disabled options
|
||||
Object.keys(settings[game][setting]).forEach((option) => {
|
||||
if (settings[game][setting][option] === 0) {
|
||||
delete settings[game][setting][option];
|
||||
@@ -977,6 +1151,32 @@ const validateSettings = () => {
|
||||
) {
|
||||
errorMessage = `${game} // ${setting} has no values above zero!`;
|
||||
}
|
||||
|
||||
// Remove weights from options with only one possibility
|
||||
if (
|
||||
Object.keys(settings[game][setting]).length === 1 &&
|
||||
!Array.isArray(settings[game][setting]) &&
|
||||
setting !== 'start_inventory'
|
||||
) {
|
||||
settings[game][setting] = Object.keys(settings[game][setting])[0];
|
||||
}
|
||||
|
||||
// Remove empty arrays
|
||||
else if (
|
||||
['exclude_locations', 'priority_locations', 'local_items',
|
||||
'non_local_items', 'start_hints', 'start_location_hints'].includes(setting) &&
|
||||
settings[game][setting].length === 0
|
||||
) {
|
||||
delete settings[game][setting];
|
||||
}
|
||||
|
||||
// Remove empty start inventory
|
||||
else if (
|
||||
setting === 'start_inventory' &&
|
||||
Object.keys(settings[game]['start_inventory']).length === 0
|
||||
) {
|
||||
delete settings[game]['start_inventory'];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -984,6 +1184,11 @@ const validateSettings = () => {
|
||||
errorMessage = 'You have not chosen a game to play!';
|
||||
}
|
||||
|
||||
// Remove weights if there is only one game
|
||||
else if (Object.keys(settings.game).length === 1) {
|
||||
settings.game = Object.keys(settings.game)[0];
|
||||
}
|
||||
|
||||
// If an error occurred, alert the user and do not export the file
|
||||
if (errorMessage) {
|
||||
userMessage.innerText = errorMessage;
|
||||
@@ -1027,6 +1232,7 @@ const generateGame = (raceMode = false) => {
|
||||
weights: { player: JSON.stringify(settings) },
|
||||
presetData: { player: JSON.stringify(settings) },
|
||||
playerCount: 1,
|
||||
spoiler: 3,
|
||||
race: raceMode ? '1' : '0',
|
||||
}).then((response) => {
|
||||
window.location.href = response.data.url;
|
||||
|
||||
BIN
WebHostLib/static/static/button-images/popover.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
WebHostLib/static/static/icons/sc2/advanceballistics.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
WebHostLib/static/static/icons/sc2/autoturretblackops.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
WebHostLib/static/static/icons/sc2/biomechanicaldrone.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
WebHostLib/static/static/icons/sc2/burstcapacitors.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
WebHostLib/static/static/icons/sc2/cyclone.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
WebHostLib/static/static/icons/sc2/drillingclaws.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
WebHostLib/static/static/icons/sc2/emergencythrusters.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
WebHostLib/static/static/icons/sc2/hellionbattlemode.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
WebHostLib/static/static/icons/sc2/hyperflightrotors.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
WebHostLib/static/static/icons/sc2/hyperfluxor.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
WebHostLib/static/static/icons/sc2/impalerrounds.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
WebHostLib/static/static/icons/sc2/improvedburstlaser.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
WebHostLib/static/static/icons/sc2/improvedsiegemode.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
WebHostLib/static/static/icons/sc2/interferencematrix.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
WebHostLib/static/static/icons/sc2/internalizedtechmodule.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
WebHostLib/static/static/icons/sc2/jotunboosters.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
WebHostLib/static/static/icons/sc2/jumpjets.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
WebHostLib/static/static/icons/sc2/lasertargetingsystem.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
WebHostLib/static/static/icons/sc2/liberator.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
WebHostLib/static/static/icons/sc2/lockdown.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
WebHostLib/static/static/icons/sc2/magfieldaccelerator.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
WebHostLib/static/static/icons/sc2/magrailmunitions.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
WebHostLib/static/static/icons/sc2/opticalflare.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
WebHostLib/static/static/icons/sc2/optimizedlogistics.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
WebHostLib/static/static/icons/sc2/reapercombatdrugs.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
WebHostLib/static/static/icons/sc2/restoration.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
WebHostLib/static/static/icons/sc2/ripwavemissiles.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
WebHostLib/static/static/icons/sc2/shreddermissile.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
WebHostLib/static/static/icons/sc2/siegetank-spidermines.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
WebHostLib/static/static/icons/sc2/siegetankrange.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
WebHostLib/static/static/icons/sc2/specialordance.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
WebHostLib/static/static/icons/sc2/spidermine.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
WebHostLib/static/static/icons/sc2/staticempblast.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
WebHostLib/static/static/icons/sc2/superstimpack.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
WebHostLib/static/static/icons/sc2/targetingoptics.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
WebHostLib/static/static/icons/sc2/terran-cloak-color.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
WebHostLib/static/static/icons/sc2/terran-emp-color.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
BIN
WebHostLib/static/static/icons/sc2/thorsiegemode.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
WebHostLib/static/static/icons/sc2/transformationservos.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
WebHostLib/static/static/icons/sc2/valkyrie.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |