mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-16 04:23:45 -07:00
Compare commits
280 Commits
0.3.6
...
allow_coll
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ce892cbb8 | ||
|
|
bc7389fbaa | ||
|
|
9cb5a7fc3a | ||
|
|
e433246f0c | ||
|
|
3a190a8fb2 | ||
|
|
4b7033fce7 | ||
|
|
37499b40a1 | ||
|
|
ca2c0e6ce2 | ||
|
|
2a28a6de28 | ||
|
|
573a1a8402 | ||
|
|
060ee926e7 | ||
|
|
df55455fc0 | ||
|
|
4d7bd929bc | ||
|
|
030e41363a | ||
|
|
4bc0e84a7f | ||
|
|
070a92e76c | ||
|
|
39563cc347 | ||
|
|
54cce4c392 | ||
|
|
426a81a065 | ||
|
|
04e6a8eae8 | ||
|
|
0cfdc973f6 | ||
|
|
f3ca0a21c9 | ||
|
|
5fef41eb97 | ||
|
|
4068ba2f15 | ||
|
|
b1599c557f | ||
|
|
7fdf38b2ad | ||
|
|
2e76085cf1 | ||
|
|
c61f467218 | ||
|
|
942d689093 | ||
|
|
5e1aa52373 | ||
|
|
a95e51deda | ||
|
|
738319462d | ||
|
|
e3deb822ad | ||
|
|
d57314a407 | ||
|
|
5a8e6e61f5 | ||
|
|
17e90ce12c | ||
|
|
016157a0eb | ||
|
|
5b64c5f934 | ||
|
|
414166f6a2 | ||
|
|
e6109394ad | ||
|
|
8ca25fed63 | ||
|
|
227d59ecfb | ||
|
|
08c17c83d4 | ||
|
|
efb2ab4505 | ||
|
|
3a68ce3faa | ||
|
|
e78800d1bc | ||
|
|
96d7a3a64c | ||
|
|
30b70b2055 | ||
|
|
cd234fc04a | ||
|
|
d74c4c4c94 | ||
|
|
a4b61118cf | ||
|
|
9fa1f4e85f | ||
|
|
3a926849a0 | ||
|
|
798d823397 | ||
|
|
4ea582f14e | ||
|
|
21fb16291d | ||
|
|
805f33c39e | ||
|
|
0cf8206660 | ||
|
|
2c20b56478 | ||
|
|
1d2f7d8669 | ||
|
|
0733775f2c | ||
|
|
d6f3b27695 | ||
|
|
ce7e6bcf33 | ||
|
|
2c4658a7e0 | ||
|
|
79b8733b13 | ||
|
|
9cb9cbe47d | ||
|
|
7cad53c31a | ||
|
|
f3bdf0c5ed | ||
|
|
af7d0dbf37 | ||
|
|
0286edf20c | ||
|
|
05e36cab1c | ||
|
|
50425985c4 | ||
|
|
062d6eeace | ||
|
|
6c460bcbf7 | ||
|
|
b8659d28cc | ||
|
|
0b12d80008 | ||
|
|
5966aa5327 | ||
|
|
7c68e91d4a | ||
|
|
1d6ab13015 | ||
|
|
cb3d40624c | ||
|
|
0eb66957b1 | ||
|
|
4311e8dbe2 | ||
|
|
53e2232f29 | ||
|
|
ecd2675ea8 | ||
|
|
fc2e555b4a | ||
|
|
df020bb389 | ||
|
|
7760034ff7 | ||
|
|
3e7794d5dc | ||
|
|
a3e8bb474a | ||
|
|
e4c95c940a | ||
|
|
daa1809a0f | ||
|
|
0a1261eb84 | ||
|
|
b62be6f7f4 | ||
|
|
ce2553a2b3 | ||
|
|
18c4b4b1fe | ||
|
|
a85ca9cc87 | ||
|
|
ad4846cedd | ||
|
|
b20be3ccec | ||
|
|
8af7908cd0 | ||
|
|
f078750b72 | ||
|
|
7cbeb8438b | ||
|
|
f7a0542898 | ||
|
|
cc61f16e57 | ||
|
|
9e3c2e2464 | ||
|
|
f528175d8a | ||
|
|
803d7105a1 | ||
|
|
a40f6058b5 | ||
|
|
0ff3c693d5 | ||
|
|
873a374a69 | ||
|
|
60584b7617 | ||
|
|
e24a85ca5c | ||
|
|
cc0540d3fb | ||
|
|
c360b9266c | ||
|
|
6148213e43 | ||
|
|
ff175008a1 | ||
|
|
cae1e683e2 | ||
|
|
fb1a9e9c5a | ||
|
|
555a0da46d | ||
|
|
0817305d5b | ||
|
|
995c978628 | ||
|
|
4de7ebd8b0 | ||
|
|
3cef39a387 | ||
|
|
ffff9ece55 | ||
|
|
dc2aa5f41e | ||
|
|
428344b6bc | ||
|
|
ea2175cb8a | ||
|
|
11873e059a | ||
|
|
6c1023a88c | ||
|
|
0be0732a2b | ||
|
|
d961022bff | ||
|
|
c9aa283711 | ||
|
|
cf2204a861 | ||
|
|
dfdcad28e5 | ||
|
|
ab4324c901 | ||
|
|
1e251dcdc0 | ||
|
|
9c1f7bfea9 | ||
|
|
5393563700 | ||
|
|
28576f2b0d | ||
|
|
ba519fecd0 | ||
|
|
86fb450ecc | ||
|
|
920240cb6f | ||
|
|
53dd0d5a7d | ||
|
|
807f544b26 | ||
|
|
1d1693df62 | ||
|
|
51574959ec | ||
|
|
04f726aef2 | ||
|
|
8a4298e504 | ||
|
|
e7f8f40464 | ||
|
|
847582ff5f | ||
|
|
1a44f5cf1c | ||
|
|
032bc75070 | ||
|
|
fb47483212 | ||
|
|
d185df3972 | ||
|
|
941dcb60e5 | ||
|
|
25756831b7 | ||
|
|
9add1495d5 | ||
|
|
34dba007dc | ||
|
|
02d3eef565 | ||
|
|
c839a76fe7 | ||
|
|
29e1c3dcf4 | ||
|
|
f6616da5a9 | ||
|
|
8678e02d54 | ||
|
|
2f37bedc92 | ||
|
|
91fdfe3e17 | ||
|
|
a41b0051a6 | ||
|
|
b8abe9f980 | ||
|
|
dd3ae5ecbd | ||
|
|
e96602d31b | ||
|
|
81d953daa3 | ||
|
|
bd774a454e | ||
|
|
ca724c92ad | ||
|
|
11eebbbd32 | ||
|
|
608794cded | ||
|
|
816de5ff02 | ||
|
|
0b941e2268 | ||
|
|
57713cda50 | ||
|
|
f56cdd6ec3 | ||
|
|
773c517757 | ||
|
|
2509b7fa3f | ||
|
|
10652d23e0 | ||
|
|
f0bc3d33ac | ||
|
|
92d1ed60c6 | ||
|
|
fe2b431821 | ||
|
|
0cc83698f9 | ||
|
|
428f643b07 | ||
|
|
d4e2b75520 | ||
|
|
96cc7f79dc | ||
|
|
bdfbc7e14a | ||
|
|
94c6562f82 | ||
|
|
22fe31a141 | ||
|
|
72fa19ee1f | ||
|
|
d899e918b4 | ||
|
|
33d31c4f0f | ||
|
|
9c3c69702a | ||
|
|
dae1a3e0f9 | ||
|
|
1f1ef10cfe | ||
|
|
760af59308 | ||
|
|
3dd7e3e706 | ||
|
|
189b129dca | ||
|
|
092e8d14ad | ||
|
|
4cfc73b582 | ||
|
|
ff9c11d772 | ||
|
|
b83aec5c12 | ||
|
|
caf63dd737 | ||
|
|
395d35571c | ||
|
|
e0be79639c | ||
|
|
37b7f0d32d | ||
|
|
50677ee6a2 | ||
|
|
f8bc3359c7 | ||
|
|
6e537e17e6 | ||
|
|
e853fc208b | ||
|
|
1a36da33b4 | ||
|
|
56fc614588 | ||
|
|
47f1fcf382 | ||
|
|
51c6be047f | ||
|
|
2c46c48ba9 | ||
|
|
32820ba653 | ||
|
|
6173bc6e03 | ||
|
|
e71ea94fe5 | ||
|
|
e3f169b4c3 | ||
|
|
e4e74074f0 | ||
|
|
149630d532 | ||
|
|
2dcfbff751 | ||
|
|
ec45479c52 | ||
|
|
aee0df5359 | ||
|
|
2cdd03f786 | ||
|
|
ce42fda85f | ||
|
|
78a18dee4e | ||
|
|
b7d46004e2 | ||
|
|
c3fe341736 | ||
|
|
79bb43b77c | ||
|
|
bedc78d335 | ||
|
|
1b582e5b09 | ||
|
|
f278dd95c5 | ||
|
|
92f75f3e03 | ||
|
|
f5adc7bdc5 | ||
|
|
78d4da53a7 | ||
|
|
e206c065bf | ||
|
|
5273812039 | ||
|
|
7c3af68e59 | ||
|
|
449973687b | ||
|
|
f5638552cc | ||
|
|
78ee19de51 | ||
|
|
82444229be | ||
|
|
2cc03d003a | ||
|
|
0e4fa378dd | ||
|
|
ffc000ec91 | ||
|
|
32b8f9f9f3 | ||
|
|
4412434976 | ||
|
|
9bdbced51f | ||
|
|
bd574ef261 | ||
|
|
45719eb7e0 | ||
|
|
d81fd280fa | ||
|
|
6b57275859 | ||
|
|
63f012cce7 | ||
|
|
679cb3e197 | ||
|
|
38b5a90c07 | ||
|
|
203f17f0f6 | ||
|
|
65995cd586 | ||
|
|
64e2d55e92 | ||
|
|
ef66f64030 | ||
|
|
e641c3ca1b | ||
|
|
111c3186bd | ||
|
|
f0e9080108 | ||
|
|
fd8867c782 | ||
|
|
f81d2653e0 | ||
|
|
1288f15e45 | ||
|
|
cde2a6e754 | ||
|
|
81dd1e359b | ||
|
|
8dffd87bee | ||
|
|
67be80e59d | ||
|
|
ff1f5569e7 | ||
|
|
8b9b482972 | ||
|
|
d0ce44cd38 | ||
|
|
aae78a8a12 | ||
|
|
7a5e11e8d4 | ||
|
|
a9ab53cb8b | ||
|
|
5ed8c2e1c0 | ||
|
|
67128ece38 | ||
|
|
8aed24151f |
37
.github/workflows/build.yml
vendored
37
.github/workflows/build.yml
vendored
@@ -2,10 +2,20 @@
|
||||
|
||||
name: Build
|
||||
|
||||
on: workflow_dispatch
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/workflows/build.yml'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build.yml'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
SNI_VERSION: v0.0.84
|
||||
ENEMIZER_VERSION: 7.1
|
||||
APPIMAGETOOL_VERSION: 13
|
||||
|
||||
@@ -15,15 +25,13 @@ jobs:
|
||||
build-win-py38: # RCs will still be built and signed by hand
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.8'
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/${Env:SNI_VERSION}/sni-${Env:SNI_VERSION}-windows-amd64.zip -OutFile sni.zip
|
||||
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
|
||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
|
||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||
- name: Build
|
||||
@@ -39,7 +47,7 @@ jobs:
|
||||
Rename-Item exe.$NAME Archipelago
|
||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||
- name: Store 7z
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ env.ZIP_NAME }}
|
||||
path: dist/${{ env.ZIP_NAME }}
|
||||
@@ -49,14 +57,14 @@ jobs:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
# - copy code below to release.yml -
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
||||
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
- name: Install build-time dependencies
|
||||
@@ -69,18 +77,15 @@ jobs:
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
|
||||
tar xf sni-*.tar.xz
|
||||
rm sni-*.tar.xz
|
||||
mv sni-* SNI
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
|
||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||
- name: Build
|
||||
run: |
|
||||
# pygobject is an optional dependency for kivy that's not in requirements
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer
|
||||
pip install -r requirements.txt
|
||||
python setup.py build_exe --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
@@ -92,13 +97,13 @@ jobs:
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - copy code above to release.yml -
|
||||
- name: Store AppImage
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ env.APPIMAGE_NAME }}
|
||||
path: dist/${{ env.APPIMAGE_NAME }}
|
||||
retention-days: 7
|
||||
- name: Store .tar.gz
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ env.TAR_NAME }}
|
||||
path: dist/${{ env.TAR_NAME }}
|
||||
|
||||
16
.github/workflows/codeql-analysis.yml
vendored
16
.github/workflows/codeql-analysis.yml
vendored
@@ -14,9 +14,17 @@ name: "CodeQL"
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '**.py'
|
||||
- '**.js'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '**.py'
|
||||
- '**.js'
|
||||
- '.github/workflows/codeql-analysis.yml'
|
||||
schedule:
|
||||
- cron: '44 8 * * 1'
|
||||
|
||||
@@ -35,11 +43,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -50,7 +58,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -64,4 +72,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
12
.github/workflows/lint.yml
vendored
12
.github/workflows/lint.yml
vendored
@@ -3,7 +3,13 @@
|
||||
|
||||
name: lint
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.py'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.py'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -11,9 +17,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Install dependencies
|
||||
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -8,7 +8,6 @@ on:
|
||||
- '*.*.*'
|
||||
|
||||
env:
|
||||
SNI_VERSION: v0.0.84
|
||||
ENEMIZER_VERSION: 7.1
|
||||
APPIMAGETOOL_VERSION: 13
|
||||
|
||||
@@ -36,14 +35,14 @@ jobs:
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
# - code below copied from build.yml -
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install base dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
|
||||
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
|
||||
- name: Get a recent python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
- name: Install build-time dependencies
|
||||
@@ -56,18 +55,15 @@ jobs:
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
|
||||
tar xf sni-*.tar.xz
|
||||
rm sni-*.tar.xz
|
||||
mv sni-* SNI
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
|
||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||
- name: Build
|
||||
run: |
|
||||
# pygobject is an optional dependency for kivy that's not in requirements
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
|
||||
# charset-normalizer was somehow incomplete in the github runner
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes bdist_appimage --yes
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
|
||||
28
.github/workflows/unittests.yml
vendored
28
.github/workflows/unittests.yml
vendored
@@ -3,7 +3,25 @@
|
||||
|
||||
name: unittests
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**'
|
||||
- '!docs/**'
|
||||
- '!setup.py'
|
||||
- '!*.iss'
|
||||
- '!.gitignore'
|
||||
- '!.github/workflows/**'
|
||||
- '.github/workflows/unittests.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**'
|
||||
- '!docs/**'
|
||||
- '!setup.py'
|
||||
- '!*.iss'
|
||||
- '!.gitignore'
|
||||
- '!.github/workflows/**'
|
||||
- '.github/workflows/unittests.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -23,11 +41,13 @@ jobs:
|
||||
os: windows-latest
|
||||
- python: {version: '3.10'} # current
|
||||
os: windows-latest
|
||||
- python: {version: '3.10'} # current
|
||||
os: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python.version }}
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python.version }}
|
||||
- name: Install dependencies
|
||||
@@ -37,4 +57,4 @@ jobs:
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
- name: Unittests
|
||||
run: |
|
||||
pytest test
|
||||
pytest
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -4,9 +4,11 @@
|
||||
*_Spoiler.txt
|
||||
*.bmbp
|
||||
*.apbp
|
||||
*.apl2ac
|
||||
*.apm3
|
||||
*.apmc
|
||||
*.apz5
|
||||
*.aptloz
|
||||
*.pyc
|
||||
*.pyd
|
||||
*.sfc
|
||||
@@ -48,7 +50,8 @@ Output Logs/
|
||||
/freeze_requirements.txt
|
||||
/Archipelago.zip
|
||||
/setup.ini
|
||||
|
||||
/installdelete.iss
|
||||
/data/user.kv
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -135,6 +138,8 @@ venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.code-workspace
|
||||
shell.nix
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
@@ -164,6 +169,7 @@ cython_debug/
|
||||
jdk*/
|
||||
minecraft*/
|
||||
minecraft_versions.json
|
||||
!worlds/minecraft/
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
703
BaseClasses.py
703
BaseClasses.py
@@ -1,20 +1,19 @@
|
||||
from __future__ import annotations
|
||||
from argparse import Namespace
|
||||
|
||||
import copy
|
||||
from enum import unique, IntEnum, IntFlag
|
||||
import logging
|
||||
import json
|
||||
import functools
|
||||
from collections import OrderedDict, Counter, deque
|
||||
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
|
||||
import typing # this can go away when Python 3.8 support is dropped
|
||||
import secrets
|
||||
import logging
|
||||
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 enum import IntEnum, IntFlag
|
||||
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
|
||||
|
||||
import NetUtils
|
||||
import Options
|
||||
import Utils
|
||||
import NetUtils
|
||||
|
||||
|
||||
class Group(TypedDict, total=False):
|
||||
@@ -26,6 +25,21 @@ class Group(TypedDict, total=False):
|
||||
replacement_items: Dict[int, Optional[str]]
|
||||
local_items: Set[str]
|
||||
non_local_items: Set[str]
|
||||
link_replacement: bool
|
||||
|
||||
|
||||
class ThreadBarrierProxy():
|
||||
"""Passes through getattr while passthrough is True"""
|
||||
def __init__(self, obj: Any):
|
||||
self.passthrough = True
|
||||
self.obj = obj
|
||||
|
||||
def __getattr__(self, item):
|
||||
if self.passthrough:
|
||||
return getattr(self.obj, item)
|
||||
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.")
|
||||
|
||||
|
||||
class MultiWorld():
|
||||
@@ -47,18 +61,28 @@ class MultiWorld():
|
||||
precollected_items: Dict[int, List[Item]]
|
||||
state: CollectionState
|
||||
|
||||
plando_options: PlandoOptions
|
||||
accessibility: Dict[int, Options.Accessibility]
|
||||
early_items: Dict[int, Dict[str, int]]
|
||||
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]]
|
||||
exclude_locations: Dict[int, Options.ExcludeLocations]
|
||||
priority_locations: Dict[int, Options.PriorityLocations]
|
||||
start_inventory: Dict[int, Options.StartInventory]
|
||||
start_hints: Dict[int, Options.StartHints]
|
||||
start_location_hints: Dict[int, Options.StartLocationHints]
|
||||
item_links: Dict[int, Options.ItemLinks]
|
||||
|
||||
game: Dict[int, str]
|
||||
|
||||
random: random.Random
|
||||
per_slot_randoms: Dict[int, random.Random]
|
||||
|
||||
class AttributeProxy():
|
||||
def __init__(self, rule):
|
||||
self.rule = rule
|
||||
@@ -67,7 +91,8 @@ class MultiWorld():
|
||||
return self.rule(player)
|
||||
|
||||
def __init__(self, players: int):
|
||||
self.random = random.Random() # world-local random state is saved for multiple generations running concurrently
|
||||
# world-local random state is saved for multiple generations running concurrently
|
||||
self.random = ThreadBarrierProxy(random.Random())
|
||||
self.players = players
|
||||
self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids}
|
||||
self.glitch_triforce = False
|
||||
@@ -158,9 +183,10 @@ class MultiWorld():
|
||||
set_player_attr('completion_condition', lambda state: True)
|
||||
self.custom_data = {}
|
||||
self.worlds = {}
|
||||
self.slot_seeds = {}
|
||||
self.per_slot_randoms = {}
|
||||
self.plando_options = PlandoOptions.none
|
||||
|
||||
def get_all_ids(self):
|
||||
def get_all_ids(self) -> Tuple[int, ...]:
|
||||
return self.player_ids + tuple(self.groups)
|
||||
|
||||
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
|
||||
@@ -203,8 +229,8 @@ class MultiWorld():
|
||||
else:
|
||||
self.random.seed(self.seed)
|
||||
self.seed_name = name if name else str(self.seed)
|
||||
self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in
|
||||
range(1, self.players + 1)}
|
||||
self.per_slot_randoms = {player: random.Random(self.random.getrandbits(64)) for player in
|
||||
range(1, self.players + 1)}
|
||||
|
||||
def set_options(self, args: Namespace) -> None:
|
||||
for option_key in Options.common_options:
|
||||
@@ -222,27 +248,32 @@ class MultiWorld():
|
||||
|
||||
def set_item_links(self):
|
||||
item_links = {}
|
||||
|
||||
replacement_prio = [False, True, None]
|
||||
for player in self.player_ids:
|
||||
for item_link in self.item_links[player].value:
|
||||
if item_link["name"] in item_links:
|
||||
if item_links[item_link["name"]]["game"] != self.game[player]:
|
||||
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
|
||||
item_links[item_link["name"]]["players"][player] = item_link["replacement_item"]
|
||||
item_links[item_link["name"]]["item_pool"] &= set(item_link["item_pool"])
|
||||
item_links[item_link["name"]]["exclude"] |= set(item_link.get("exclude", []))
|
||||
item_links[item_link["name"]]["local_items"] &= set(item_link.get("local_items", []))
|
||||
item_links[item_link["name"]]["non_local_items"] &= set(item_link.get("non_local_items", []))
|
||||
current_link = item_links[item_link["name"]]
|
||||
current_link["players"][player] = item_link["replacement_item"]
|
||||
current_link["item_pool"] &= set(item_link["item_pool"])
|
||||
current_link["exclude"] |= set(item_link.get("exclude", []))
|
||||
current_link["local_items"] &= set(item_link.get("local_items", []))
|
||||
current_link["non_local_items"] &= set(item_link.get("non_local_items", []))
|
||||
current_link["link_replacement"] = min(current_link["link_replacement"],
|
||||
replacement_prio.index(item_link["link_replacement"]))
|
||||
else:
|
||||
if item_link["name"] in self.player_name.values():
|
||||
raise Exception(f"Cannot name a ItemLink group the same as a player ({item_link['name']}) ({self.get_player_name(player)}).")
|
||||
raise Exception(f"Cannot name a ItemLink group the same as a player ({item_link['name']}) "
|
||||
f"({self.get_player_name(player)}).")
|
||||
item_links[item_link["name"]] = {
|
||||
"players": {player: item_link["replacement_item"]},
|
||||
"item_pool": set(item_link["item_pool"]),
|
||||
"exclude": set(item_link.get("exclude", [])),
|
||||
"game": self.game[player],
|
||||
"local_items": set(item_link.get("local_items", [])),
|
||||
"non_local_items": set(item_link.get("non_local_items", []))
|
||||
"non_local_items": set(item_link.get("non_local_items", [])),
|
||||
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
||||
}
|
||||
|
||||
for name, item_link in item_links.items():
|
||||
@@ -267,10 +298,12 @@ class MultiWorld():
|
||||
for group_name, item_link in item_links.items():
|
||||
game = item_link["game"]
|
||||
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
|
||||
|
||||
group["item_pool"] = item_link["item_pool"]
|
||||
group["replacement_items"] = item_link["players"]
|
||||
group["local_items"] = item_link["local_items"]
|
||||
group["non_local_items"] = item_link["non_local_items"]
|
||||
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
|
||||
|
||||
# intended for unittests
|
||||
def set_default_common_options(self):
|
||||
@@ -281,15 +314,15 @@ class MultiWorld():
|
||||
self.state = CollectionState(self)
|
||||
|
||||
def secure(self):
|
||||
self.random = secrets.SystemRandom()
|
||||
self.random = ThreadBarrierProxy(secrets.SystemRandom())
|
||||
self.is_race = True
|
||||
|
||||
@functools.cached_property
|
||||
def player_ids(self):
|
||||
def player_ids(self) -> Tuple[int, ...]:
|
||||
return tuple(range(1, self.players + 1))
|
||||
|
||||
@functools.lru_cache()
|
||||
def get_game_players(self, game_name: str):
|
||||
def get_game_players(self, game_name: str) -> Tuple[int, ...]:
|
||||
return tuple(player for player in self.player_ids if self.game[player] == game_name)
|
||||
|
||||
@functools.lru_cache()
|
||||
@@ -308,10 +341,7 @@ class MultiWorld():
|
||||
|
||||
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 """
|
||||
return f"AP_{self.seed_name}_P{player}" \
|
||||
+ (f"_{self.get_file_safe_player_name(player).replace(' ', '_')}"
|
||||
if (self.player_name[player] != f"Player{player}")
|
||||
else '')
|
||||
return f"AP_{self.seed_name}_P{player}_{self.get_file_safe_player_name(player).replace(' ', '_')}"
|
||||
|
||||
def initialize_regions(self, regions=None):
|
||||
for region in regions if regions else self.regions:
|
||||
@@ -386,7 +416,12 @@ class MultiWorld():
|
||||
def get_items(self) -> List[Item]:
|
||||
return [loc.item for loc in self.get_filled_locations()] + self.itempool
|
||||
|
||||
def find_item_locations(self, item, player: int) -> List[Location]:
|
||||
def find_item_locations(self, item, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||
if resolve_group_locations:
|
||||
player_groups = self.get_player_groups(player)
|
||||
return [location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.player not in player_groups and
|
||||
(location.item.player == player or location.item.player in player_groups)]
|
||||
return [location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.item.player == player]
|
||||
|
||||
@@ -394,7 +429,12 @@ class MultiWorld():
|
||||
return next(location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.item.player == player)
|
||||
|
||||
def find_items_in_locations(self, items: Set[str], player: int) -> List[Location]:
|
||||
def find_items_in_locations(self, items: Set[str], player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||
if resolve_group_locations:
|
||||
player_groups = self.get_player_groups(player)
|
||||
return [location for location in self.get_locations() if
|
||||
location.item and location.item.name in items and location.player not in player_groups and
|
||||
(location.item.player == player or location.item.player in player_groups)]
|
||||
return [location for location in self.get_locations() if
|
||||
location.item and location.item.name in items and location.item.player == player]
|
||||
|
||||
@@ -427,46 +467,35 @@ class MultiWorld():
|
||||
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
|
||||
self.indirect_connections.setdefault(region, set()).add(entrance)
|
||||
|
||||
def get_locations(self) -> List[Location]:
|
||||
def get_locations(self, player: Optional[int] = None) -> List[Location]:
|
||||
if self._cached_locations is None:
|
||||
self._cached_locations = [location for region in self.regions for location in region.locations]
|
||||
if player is not None:
|
||||
return [location for location in self._cached_locations if location.player == player]
|
||||
return self._cached_locations
|
||||
|
||||
def clear_location_cache(self):
|
||||
self._cached_locations = None
|
||||
|
||||
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]:
|
||||
if player is not None:
|
||||
return [location for location in self.get_locations() if
|
||||
location.player == player and not location.item]
|
||||
return [location for location in self.get_locations() if not location.item]
|
||||
|
||||
def get_unfilled_dungeon_locations(self):
|
||||
return [location for location in self.get_locations() if not location.item and location.parent_region.dungeon]
|
||||
return [location for location in self.get_locations(player) if location.item is None]
|
||||
|
||||
def get_filled_locations(self, player: Optional[int] = None) -> List[Location]:
|
||||
if player is not None:
|
||||
return [location for location in self.get_locations() if
|
||||
location.player == player and location.item is not None]
|
||||
return [location for location in self.get_locations() if location.item is not None]
|
||||
return [location for location in self.get_locations(player) if location.item is not None]
|
||||
|
||||
def get_reachable_locations(self, state: Optional[CollectionState] = None, player: Optional[int] = None) -> List[Location]:
|
||||
if state is None:
|
||||
state = self.state
|
||||
return [location for location in self.get_locations() if
|
||||
(player is None or location.player == player) and location.can_reach(state)]
|
||||
state: CollectionState = state if state else self.state
|
||||
return [location for location in self.get_locations(player) if location.can_reach(state)]
|
||||
|
||||
def get_placeable_locations(self, state=None, player=None) -> List[Location]:
|
||||
if state is None:
|
||||
state = self.state
|
||||
return [location for location in self.get_locations() if
|
||||
(player is None or location.player == player) and location.item is None and location.can_reach(state)]
|
||||
state: CollectionState = state if state else self.state
|
||||
return [location for location in self.get_locations(player) if location.item is None and location.can_reach(state)]
|
||||
|
||||
def get_unfilled_locations_for_players(self, locations: List[str], players: Iterable[int]):
|
||||
def get_unfilled_locations_for_players(self, location_names: List[str], players: Iterable[int]):
|
||||
for player in players:
|
||||
if len(locations) == 0:
|
||||
locations = [location.name for location in self.get_unfilled_locations(player)]
|
||||
for location_name in locations:
|
||||
if not location_names:
|
||||
location_names = [location.name for location in self.get_unfilled_locations(player)]
|
||||
for location_name in location_names:
|
||||
location = self._location_cache.get((location_name, player), None)
|
||||
if location is not None and location.item is None:
|
||||
yield location
|
||||
@@ -736,169 +765,9 @@ class CollectionState():
|
||||
found += self.prog_items[item_name, player]
|
||||
return found
|
||||
|
||||
def can_buy_unlimited(self, item: str, player: int) -> bool:
|
||||
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for
|
||||
shop in self.multiworld.shops)
|
||||
|
||||
def can_buy(self, item: str, player: int) -> bool:
|
||||
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for
|
||||
shop in self.multiworld.shops)
|
||||
|
||||
def item_count(self, item: str, player: int) -> int:
|
||||
return self.prog_items[item, player]
|
||||
|
||||
def has_triforce_pieces(self, count: int, player: int) -> bool:
|
||||
return self.item_count('Triforce Piece', player) + self.item_count('Power Star', player) >= count
|
||||
|
||||
def has_crystals(self, count: int, player: int) -> bool:
|
||||
found: int = 0
|
||||
for crystalnumber in range(1, 8):
|
||||
found += self.prog_items[f"Crystal {crystalnumber}", player]
|
||||
if found >= count:
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_lift_rocks(self, player: int):
|
||||
return self.has('Power Glove', player) or self.has('Titans Mitts', player)
|
||||
|
||||
def bottle_count(self, player: int) -> int:
|
||||
return min(self.multiworld.difficulty_requirements[player].progressive_bottle_limit,
|
||||
self.count_group("Bottles", player))
|
||||
|
||||
def has_hearts(self, player: int, count: int) -> int:
|
||||
# Warning: This only considers items that are marked as advancement items
|
||||
return self.heart_count(player) >= count
|
||||
|
||||
def heart_count(self, player: int) -> int:
|
||||
# Warning: This only considers items that are marked as advancement items
|
||||
diff = self.multiworld.difficulty_requirements[player]
|
||||
return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \
|
||||
+ self.item_count('Sanctuary Heart Container', player) \
|
||||
+ min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
|
||||
+ 3 # starting hearts
|
||||
|
||||
def can_lift_heavy_rocks(self, player: int) -> bool:
|
||||
return self.has('Titans Mitts', player)
|
||||
|
||||
def can_extend_magic(self, player: int, smallmagic: int = 16,
|
||||
fullrefill: bool = False): # This reflects the total magic Link has, not the total extra he has.
|
||||
basemagic = 8
|
||||
if self.has('Magic Upgrade (1/4)', player):
|
||||
basemagic = 32
|
||||
elif self.has('Magic Upgrade (1/2)', player):
|
||||
basemagic = 16
|
||||
if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player):
|
||||
if self.multiworld.item_functionality[player] == 'hard' and not fullrefill:
|
||||
basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player))
|
||||
elif self.multiworld.item_functionality[player] == 'expert' and not fullrefill:
|
||||
basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player))
|
||||
else:
|
||||
basemagic = basemagic + basemagic * self.bottle_count(player)
|
||||
return basemagic >= smallmagic
|
||||
|
||||
def can_kill_most_things(self, player: int, enemies: int = 5) -> bool:
|
||||
return (self.has_melee_weapon(player)
|
||||
or self.has('Cane of Somaria', player)
|
||||
or (self.has('Cane of Byrna', player) and (enemies < 6 or self.can_extend_magic(player)))
|
||||
or self.can_shoot_arrows(player)
|
||||
or self.has('Fire Rod', player)
|
||||
or (self.has('Bombs (10)', player) and enemies < 6))
|
||||
|
||||
def can_shoot_arrows(self, player: int) -> bool:
|
||||
if self.multiworld.retro_bow[player]:
|
||||
return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player)
|
||||
return self.has('Bow', player) or self.has('Silver Bow', player)
|
||||
|
||||
def can_get_good_bee(self, player: int) -> bool:
|
||||
cave = self.multiworld.get_region('Good Bee Cave', player)
|
||||
return (
|
||||
self.has_group("Bottles", player) and
|
||||
self.has('Bug Catching Net', player) and
|
||||
(self.has('Pegasus Boots', player) or (self.has_sword(player) and self.has('Quake', player))) and
|
||||
cave.can_reach(self) and
|
||||
self.is_not_bunny(cave, player)
|
||||
)
|
||||
|
||||
def can_retrieve_tablet(self, player: int) -> bool:
|
||||
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
|
||||
(self.multiworld.swordless[player] and
|
||||
self.has("Hammer", player)))
|
||||
|
||||
def has_sword(self, player: int) -> bool:
|
||||
return self.has('Fighter Sword', player) \
|
||||
or self.has('Master Sword', player) \
|
||||
or self.has('Tempered Sword', player) \
|
||||
or self.has('Golden Sword', player)
|
||||
|
||||
def has_beam_sword(self, player: int) -> bool:
|
||||
return self.has('Master Sword', player) or self.has('Tempered Sword', player) or self.has('Golden Sword',
|
||||
player)
|
||||
|
||||
def has_melee_weapon(self, player: int) -> bool:
|
||||
return self.has_sword(player) or self.has('Hammer', player)
|
||||
|
||||
def has_fire_source(self, player: int) -> bool:
|
||||
return self.has('Fire Rod', player) or self.has('Lamp', player)
|
||||
|
||||
def can_melt_things(self, player: int) -> bool:
|
||||
return self.has('Fire Rod', player) or \
|
||||
(self.has('Bombos', player) and
|
||||
(self.multiworld.swordless[player] or
|
||||
self.has_sword(player)))
|
||||
|
||||
def can_avoid_lasers(self, player: int) -> bool:
|
||||
return self.has('Mirror Shield', player) or self.has('Cane of Byrna', player) or self.has('Cape', player)
|
||||
|
||||
def is_not_bunny(self, region: Region, player: int) -> bool:
|
||||
if self.has('Moon Pearl', player):
|
||||
return True
|
||||
|
||||
return region.is_light_world if self.multiworld.mode[player] != 'inverted' else region.is_dark_world
|
||||
|
||||
def can_reach_light_world(self, player: int) -> bool:
|
||||
if True in [i.is_light_world for i in self.reachable_regions[player]]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_reach_dark_world(self, player: int) -> bool:
|
||||
if True in [i.is_dark_world for i in self.reachable_regions[player]]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_misery_mire_medallion(self, player: int) -> bool:
|
||||
return self.has(self.multiworld.required_medallions[player][0], player)
|
||||
|
||||
def has_turtle_rock_medallion(self, player: int) -> bool:
|
||||
return self.has(self.multiworld.required_medallions[player][1], player)
|
||||
|
||||
def can_boots_clip_lw(self, player: int) -> bool:
|
||||
if self.multiworld.mode[player] == 'inverted':
|
||||
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
|
||||
return self.has('Pegasus Boots', player)
|
||||
|
||||
def can_boots_clip_dw(self, player: int) -> bool:
|
||||
if self.multiworld.mode[player] != 'inverted':
|
||||
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
|
||||
return self.has('Pegasus Boots', player)
|
||||
|
||||
def can_get_glitched_speed_lw(self, player: int) -> bool:
|
||||
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
||||
if self.multiworld.mode[player] == 'inverted':
|
||||
rules.append(self.has('Moon Pearl', player))
|
||||
return all(rules)
|
||||
|
||||
def can_superbunny_mirror_with_sword(self, player: int) -> bool:
|
||||
return self.has('Magic Mirror', player) and self.has_sword(player)
|
||||
|
||||
def can_get_glitched_speed_dw(self, player: int) -> bool:
|
||||
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
|
||||
if self.multiworld.mode[player] != 'inverted':
|
||||
rules.append(self.has('Moon Pearl', player))
|
||||
return all(rules)
|
||||
|
||||
def can_bomb_clip(self, region: Region, player: int) -> bool:
|
||||
return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player)
|
||||
|
||||
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
|
||||
if location:
|
||||
self.locations_checked.add(location)
|
||||
@@ -925,45 +794,23 @@ class CollectionState():
|
||||
self.stale[item.player] = True
|
||||
|
||||
|
||||
@unique
|
||||
class RegionType(IntEnum):
|
||||
Generic = 0
|
||||
LightWorld = 1
|
||||
DarkWorld = 2
|
||||
Cave = 3 # Also includes Houses
|
||||
Dungeon = 4
|
||||
|
||||
@property
|
||||
def is_indoors(self) -> bool:
|
||||
"""Shorthand for checking if Cave or Dungeon"""
|
||||
return self in (RegionType.Cave, RegionType.Dungeon)
|
||||
|
||||
|
||||
class Region:
|
||||
name: str
|
||||
type: RegionType
|
||||
hint_text: str
|
||||
_hint_text: str
|
||||
player: int
|
||||
multiworld: Optional[MultiWorld]
|
||||
entrances: List[Entrance]
|
||||
exits: List[Entrance]
|
||||
locations: List[Location]
|
||||
dungeon: Optional[Dungeon] = None
|
||||
shop: Optional = None
|
||||
|
||||
# LttP specific. TODO: move to a LttPRegion
|
||||
# will be set after making connections.
|
||||
is_light_world: bool = False
|
||||
is_dark_world: bool = False
|
||||
|
||||
def __init__(self, name: str, type_: RegionType, hint: str, player: int, world: Optional[MultiWorld] = None):
|
||||
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
|
||||
self.name = name
|
||||
self.type = type_
|
||||
self.entrances = []
|
||||
self.exits = []
|
||||
self.locations = []
|
||||
self.multiworld = world
|
||||
self.hint_text = hint
|
||||
self.multiworld = multiworld
|
||||
self._hint_text = hint
|
||||
self.player = player
|
||||
|
||||
def can_reach(self, state: CollectionState) -> bool:
|
||||
@@ -979,6 +826,10 @@ class Region:
|
||||
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):
|
||||
@@ -1116,7 +967,7 @@ class Location:
|
||||
self.parent_region = parent
|
||||
|
||||
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
|
||||
return (self.always_allow(state, item)
|
||||
return ((self.always_allow(state, item) and item.name not in state.multiworld.non_local_items[item.player])
|
||||
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
|
||||
and self.item_rule(item)
|
||||
and (not check_access or self.can_reach(state))))
|
||||
@@ -1248,13 +1099,9 @@ class Spoiler():
|
||||
self.multiworld = world
|
||||
self.hashes = {}
|
||||
self.entrances = OrderedDict()
|
||||
self.medallions = {}
|
||||
self.playthrough = {}
|
||||
self.unreachables = set()
|
||||
self.locations = {}
|
||||
self.paths = {}
|
||||
self.shops = []
|
||||
self.bosses = OrderedDict()
|
||||
|
||||
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
|
||||
if self.multiworld.players == 1:
|
||||
@@ -1264,146 +1111,163 @@ class Spoiler():
|
||||
self.entrances[(entrance, direction, player)] = OrderedDict(
|
||||
[('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)])
|
||||
|
||||
def parse_data(self):
|
||||
self.medallions = OrderedDict()
|
||||
for player in self.multiworld.get_game_players("A Link to the Past"):
|
||||
self.medallions[f'Misery Mire ({self.multiworld.get_player_name(player)})'] = \
|
||||
self.multiworld.required_medallions[player][0]
|
||||
self.medallions[f'Turtle Rock ({self.multiworld.get_player_name(player)})'] = \
|
||||
self.multiworld.required_medallions[player][1]
|
||||
def create_playthrough(self, create_paths: bool = True):
|
||||
"""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]
|
||||
collection_spheres: List[Set[Location]] = []
|
||||
state = CollectionState(multiworld)
|
||||
sphere_candidates = set(prog_locations)
|
||||
logging.debug('Building up collection spheres.')
|
||||
while sphere_candidates:
|
||||
|
||||
self.locations = OrderedDict()
|
||||
listed_locations = set()
|
||||
# build up spheres of collection radius.
|
||||
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
||||
|
||||
lw_locations = [loc for loc in self.multiworld.get_locations() if
|
||||
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.LightWorld and loc.show_in_spoiler]
|
||||
self.locations['Light World'] = OrderedDict(
|
||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||
lw_locations])
|
||||
listed_locations.update(lw_locations)
|
||||
sphere = {location for location in sphere_candidates if state.can_reach(location)}
|
||||
|
||||
dw_locations = [loc for loc in self.multiworld.get_locations() if
|
||||
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.DarkWorld and loc.show_in_spoiler]
|
||||
self.locations['Dark World'] = OrderedDict(
|
||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||
dw_locations])
|
||||
listed_locations.update(dw_locations)
|
||||
for location in sphere:
|
||||
state.collect(location.item, True, location)
|
||||
|
||||
cave_locations = [loc for loc in self.multiworld.get_locations() if
|
||||
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.Cave and loc.show_in_spoiler]
|
||||
self.locations['Caves'] = OrderedDict(
|
||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||
cave_locations])
|
||||
listed_locations.update(cave_locations)
|
||||
sphere_candidates -= sphere
|
||||
collection_spheres.append(sphere)
|
||||
state_cache.append(state.copy())
|
||||
|
||||
for dungeon in self.multiworld.dungeons.values():
|
||||
dungeon_locations = [loc for loc in self.multiworld.get_locations() if
|
||||
loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler]
|
||||
self.locations[str(dungeon)] = OrderedDict(
|
||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||
dungeon_locations])
|
||||
listed_locations.update(dungeon_locations)
|
||||
logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres),
|
||||
len(sphere),
|
||||
len(prog_locations))
|
||||
if not sphere:
|
||||
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
||||
location.item.name, location.item.player, location.name, location.player) for location in
|
||||
sphere_candidates])
|
||||
if any([multiworld.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
|
||||
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
||||
f'Something went terribly wrong here.')
|
||||
else:
|
||||
self.unreachables = sphere_candidates
|
||||
break
|
||||
|
||||
other_locations = [loc for loc in self.multiworld.get_locations() if
|
||||
loc not in listed_locations and loc.show_in_spoiler]
|
||||
if other_locations:
|
||||
self.locations['Other Locations'] = OrderedDict(
|
||||
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
|
||||
other_locations])
|
||||
listed_locations.update(other_locations)
|
||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||
# reducing each range of influence to the bare minimum required inside it
|
||||
restore_later = {}
|
||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||
to_delete = set()
|
||||
for location in sphere:
|
||||
# we remove the item at location and check if game is still beatable
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||
location.item.player)
|
||||
old_item = location.item
|
||||
location.item = None
|
||||
if multiworld.can_beat_game(state_cache[num]):
|
||||
to_delete.add(location)
|
||||
restore_later[location] = old_item
|
||||
else:
|
||||
# still required, got to keep it around
|
||||
location.item = old_item
|
||||
|
||||
self.shops = []
|
||||
from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display
|
||||
for shop in self.multiworld.shops:
|
||||
if not shop.custom:
|
||||
continue
|
||||
shopdata = {
|
||||
'location': str(shop.region),
|
||||
'type': 'Take Any' if shop.type == ShopType.TakeAny else 'Shop'
|
||||
}
|
||||
for index, item in enumerate(shop.inventory):
|
||||
if item is None:
|
||||
continue
|
||||
my_price = item['price'] // price_rate_display.get(item['price_type'], 1)
|
||||
shopdata['item_{}'.format(
|
||||
index)] = f"{item['item']} — {my_price} {price_type_display_name[item['price_type']]}"
|
||||
# cull entries in spheres for spoiler walkthrough at end
|
||||
sphere -= to_delete
|
||||
|
||||
if item['player'] > 0:
|
||||
shopdata['item_{}'.format(index)] = shopdata['item_{}'.format(index)].replace('—',
|
||||
'(Player {}) — '.format(
|
||||
item['player']))
|
||||
|
||||
if item['max'] == 0:
|
||||
continue
|
||||
shopdata['item_{}'.format(index)] += " x {}".format(item['max'])
|
||||
|
||||
if item['replacement'] is None:
|
||||
continue
|
||||
shopdata['item_{}'.format(
|
||||
index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}"
|
||||
self.shops.append(shopdata)
|
||||
|
||||
for player in self.multiworld.get_game_players("A Link to the Past"):
|
||||
self.bosses[str(player)] = OrderedDict()
|
||||
self.bosses[str(player)]["Eastern Palace"] = self.multiworld.get_dungeon("Eastern Palace", player).boss.name
|
||||
self.bosses[str(player)]["Desert Palace"] = self.multiworld.get_dungeon("Desert Palace", player).boss.name
|
||||
self.bosses[str(player)]["Tower Of Hera"] = self.multiworld.get_dungeon("Tower of Hera", player).boss.name
|
||||
self.bosses[str(player)]["Hyrule Castle"] = "Agahnim"
|
||||
self.bosses[str(player)]["Palace Of Darkness"] = self.multiworld.get_dungeon("Palace of Darkness",
|
||||
player).boss.name
|
||||
self.bosses[str(player)]["Swamp Palace"] = self.multiworld.get_dungeon("Swamp Palace", player).boss.name
|
||||
self.bosses[str(player)]["Skull Woods"] = self.multiworld.get_dungeon("Skull Woods", player).boss.name
|
||||
self.bosses[str(player)]["Thieves Town"] = self.multiworld.get_dungeon("Thieves Town", player).boss.name
|
||||
self.bosses[str(player)]["Ice Palace"] = self.multiworld.get_dungeon("Ice Palace", player).boss.name
|
||||
self.bosses[str(player)]["Misery Mire"] = self.multiworld.get_dungeon("Misery Mire", player).boss.name
|
||||
self.bosses[str(player)]["Turtle Rock"] = self.multiworld.get_dungeon("Turtle Rock", player).boss.name
|
||||
if self.multiworld.mode[player] != 'inverted':
|
||||
self.bosses[str(player)]["Ganons Tower Basement"] = \
|
||||
self.multiworld.get_dungeon('Ganons Tower', player).bosses['bottom'].name
|
||||
self.bosses[str(player)]["Ganons Tower Middle"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
|
||||
'middle'].name
|
||||
self.bosses[str(player)]["Ganons Tower Top"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
|
||||
'top'].name
|
||||
# second phase, sphere 0
|
||||
removed_precollected = []
|
||||
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
multiworld.precollected_items[item.player].remove(item)
|
||||
multiworld.state.remove(item)
|
||||
if not multiworld.can_beat_game():
|
||||
multiworld.push_precollected(item)
|
||||
else:
|
||||
self.bosses[str(player)]["Ganons Tower Basement"] = \
|
||||
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name
|
||||
self.bosses[str(player)]["Ganons Tower Middle"] = \
|
||||
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name
|
||||
self.bosses[str(player)]["Ganons Tower Top"] = \
|
||||
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name
|
||||
removed_precollected.append(item)
|
||||
|
||||
self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2"
|
||||
self.bosses[str(player)]["Ganon"] = "Ganon"
|
||||
# we are now down to just the required progress items in collection_spheres. Unfortunately
|
||||
# the previous pruning stage could potentially have made certain items dependant on others
|
||||
# in the same or later sphere (because the location had 2 ways to access but the item originally
|
||||
# used to access it was deemed not required.) So we need to do one final sphere collection pass
|
||||
# to build up the correct spheres
|
||||
|
||||
def to_json(self):
|
||||
self.parse_data()
|
||||
out = OrderedDict()
|
||||
out['Entrances'] = list(self.entrances.values())
|
||||
out.update(self.locations)
|
||||
out['Special'] = self.medallions
|
||||
if self.hashes:
|
||||
out['Hashes'] = self.hashes
|
||||
if self.shops:
|
||||
out['Shops'] = self.shops
|
||||
out['playthrough'] = self.playthrough
|
||||
out['paths'] = self.paths
|
||||
out['Bosses'] = self.bosses
|
||||
required_locations = {item for sphere in collection_spheres for item in sphere}
|
||||
state = CollectionState(multiworld)
|
||||
collection_spheres = []
|
||||
while required_locations:
|
||||
state.sweep_for_events(key_only=True)
|
||||
|
||||
return json.dumps(out)
|
||||
sphere = set(filter(state.can_reach, required_locations))
|
||||
|
||||
for location in sphere:
|
||||
state.collect(location.item, True, location)
|
||||
|
||||
required_locations -= sphere
|
||||
|
||||
collection_spheres.append(sphere)
|
||||
|
||||
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
|
||||
len(sphere), len(required_locations))
|
||||
if not sphere:
|
||||
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
|
||||
|
||||
# we can finally output our playthrough
|
||||
self.playthrough = {"0": sorted([str(item) for item in
|
||||
chain.from_iterable(multiworld.precollected_items.values())
|
||||
if item.advancement])}
|
||||
|
||||
for i, sphere in enumerate(collection_spheres):
|
||||
self.playthrough[str(i + 1)] = {
|
||||
str(location): str(location.item) for location in sorted(sphere)}
|
||||
if create_paths:
|
||||
self.create_paths(state, collection_spheres)
|
||||
|
||||
# repair the multiworld again
|
||||
for location, item in restore_later.items():
|
||||
location.item = item
|
||||
|
||||
for item in removed_precollected:
|
||||
multiworld.push_precollected(item)
|
||||
|
||||
def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]):
|
||||
from itertools import zip_longest
|
||||
multiworld = self.multiworld
|
||||
|
||||
def flist_to_iter(node):
|
||||
while node:
|
||||
value, node = node
|
||||
yield value
|
||||
|
||||
def get_path(state, region):
|
||||
reversed_path_as_flist = state.path.get(region, (region, None))
|
||||
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
|
||||
# Now we combine the flat string list into (region, exit) pairs
|
||||
pathsiter = iter(string_path_flat)
|
||||
pathpairs = zip_longest(pathsiter, pathsiter)
|
||||
return list(pathpairs)
|
||||
|
||||
self.paths = {}
|
||||
topology_worlds = (player for player in multiworld.player_ids if multiworld.worlds[player].topology_present)
|
||||
for player in topology_worlds:
|
||||
self.paths.update(
|
||||
{str(location): get_path(state, location.parent_region)
|
||||
for sphere in collection_spheres for location in sphere
|
||||
if location.player == player})
|
||||
if player in multiworld.get_game_players("A Link to the Past"):
|
||||
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
|
||||
# Maybe move the big bomb over to the Event system instead?
|
||||
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
|
||||
for (_, exit_path) in path):
|
||||
if multiworld.mode[player] != 'inverted':
|
||||
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
|
||||
get_path(state, multiworld.get_region('Big Bomb Shop', player))
|
||||
else:
|
||||
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):
|
||||
self.parse_data()
|
||||
|
||||
def bool_to_text(variable: Union[bool, str]) -> str:
|
||||
if type(variable) == str:
|
||||
return variable
|
||||
return 'Yes' if variable else 'No'
|
||||
|
||||
def write_option(option_key: str, option_obj: type(Options.Option)):
|
||||
res = getattr(self.multiworld, option_key)[player]
|
||||
display_name = getattr(option_obj, "display_name", option_key)
|
||||
try:
|
||||
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
|
||||
outfile.write(f'{display_name + ":":33}{res.current_option_name}\n')
|
||||
except:
|
||||
raise Exception
|
||||
|
||||
@@ -1413,52 +1277,20 @@ class Spoiler():
|
||||
Utils.__version__, self.multiworld.seed))
|
||||
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
|
||||
outfile.write('Players: %d\n' % self.multiworld.players)
|
||||
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
|
||||
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
|
||||
|
||||
for player in range(1, self.multiworld.players + 1):
|
||||
if self.multiworld.players > 1:
|
||||
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
|
||||
outfile.write('Game: %s\n' % self.multiworld.game[player])
|
||||
for f_option, option in Options.per_game_common_options.items():
|
||||
|
||||
options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions)
|
||||
for f_option, option in options.items():
|
||||
write_option(f_option, option)
|
||||
options = self.multiworld.worlds[player].option_definitions
|
||||
if options:
|
||||
for f_option, option in options.items():
|
||||
write_option(f_option, option)
|
||||
|
||||
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)
|
||||
|
||||
if player in self.multiworld.get_game_players("A Link to the Past"):
|
||||
outfile.write('%s%s\n' % ('Hash: ', self.hashes[player]))
|
||||
|
||||
outfile.write('Logic: %s\n' % self.multiworld.logic[player])
|
||||
outfile.write('Dark Room Logic: %s\n' % self.multiworld.dark_room_logic[player])
|
||||
outfile.write('Mode: %s\n' % self.multiworld.mode[player])
|
||||
outfile.write('Goal: %s\n' % self.multiworld.goal[player])
|
||||
if "triforce" in self.multiworld.goal[player]: # triforce hunt
|
||||
outfile.write("Pieces available for Triforce: %s\n" %
|
||||
self.multiworld.triforce_pieces_available[player])
|
||||
outfile.write("Pieces required for Triforce: %s\n" %
|
||||
self.multiworld.triforce_pieces_required[player])
|
||||
outfile.write('Difficulty: %s\n' % self.multiworld.difficulty[player])
|
||||
outfile.write('Item Functionality: %s\n' % self.multiworld.item_functionality[player])
|
||||
outfile.write('Entrance Shuffle: %s\n' % self.multiworld.shuffle[player])
|
||||
if self.multiworld.shuffle[player] != "vanilla":
|
||||
outfile.write('Entrance Shuffle Seed %s\n' % self.multiworld.worlds[player].er_seed)
|
||||
outfile.write('Shop inventory shuffle: %s\n' %
|
||||
bool_to_text("i" in self.multiworld.shop_shuffle[player]))
|
||||
outfile.write('Shop price shuffle: %s\n' %
|
||||
bool_to_text("p" in self.multiworld.shop_shuffle[player]))
|
||||
outfile.write('Shop upgrade shuffle: %s\n' %
|
||||
bool_to_text("u" in self.multiworld.shop_shuffle[player]))
|
||||
outfile.write('New Shop inventory: %s\n' %
|
||||
bool_to_text("g" in self.multiworld.shop_shuffle[player] or
|
||||
"f" in self.multiworld.shop_shuffle[player]))
|
||||
outfile.write('Custom Potion Shop: %s\n' %
|
||||
bool_to_text("w" in self.multiworld.shop_shuffle[player]))
|
||||
outfile.write('Enemy health: %s\n' % self.multiworld.enemy_health[player])
|
||||
outfile.write('Enemy damage: %s\n' % self.multiworld.enemy_damage[player])
|
||||
outfile.write('Prize shuffle %s\n' %
|
||||
self.multiworld.shuffle_prizes[player])
|
||||
if self.entrances:
|
||||
outfile.write('\n\nEntrances:\n\n')
|
||||
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.multiworld.get_player_name(entry["player"])}: '
|
||||
@@ -1467,30 +1299,14 @@ class Spoiler():
|
||||
'<=' if entry['direction'] == 'exit' else '=>',
|
||||
entry['exit']) for entry in self.entrances.values()]))
|
||||
|
||||
if self.medallions:
|
||||
outfile.write('\n\nMedallions:\n')
|
||||
for dungeon, medallion in self.medallions.items():
|
||||
outfile.write(f'\n{dungeon}: {medallion}')
|
||||
|
||||
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]
|
||||
outfile.write('\n\nLocations:\n\n')
|
||||
outfile.write('\n'.join(
|
||||
['%s: %s' % (location, item) for grouping in self.locations.values() for (location, item) in
|
||||
grouping.items()]))
|
||||
['%s: %s' % (location, item) for location, item in locations]))
|
||||
|
||||
if self.shops:
|
||||
outfile.write('\n\nShops:\n\n')
|
||||
outfile.write('\n'.join("{} [{}]\n {}".format(shop['location'], shop['type'], "\n ".join(
|
||||
item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if
|
||||
item)) for shop in self.shops))
|
||||
|
||||
for player in self.multiworld.get_game_players("A Link to the Past"):
|
||||
if self.multiworld.boss_shuffle[player] != 'none':
|
||||
bossmap = self.bosses[str(player)] if self.multiworld.players > 1 else self.bosses
|
||||
outfile.write(
|
||||
f'\n\nBosses{(f" ({self.multiworld.get_player_name(player)})" if self.multiworld.players > 1 else "")}:\n')
|
||||
outfile.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()]))
|
||||
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 [
|
||||
@@ -1529,6 +1345,45 @@ class Tutorial(NamedTuple):
|
||||
authors: List[str]
|
||||
|
||||
|
||||
class PlandoOptions(IntFlag):
|
||||
none = 0b0000
|
||||
items = 0b0001
|
||||
connections = 0b0010
|
||||
texts = 0b0100
|
||||
bosses = 0b1000
|
||||
|
||||
@classmethod
|
||||
def from_option_string(cls, option_string: str) -> PlandoOptions:
|
||||
result = cls(0)
|
||||
for part in option_string.split(","):
|
||||
part = part.strip().lower()
|
||||
if part:
|
||||
result = cls._handle_part(part, result)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_set(cls, option_set: Set[str]) -> PlandoOptions:
|
||||
result = cls(0)
|
||||
for part in option_set:
|
||||
result = cls._handle_part(part, result)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _handle_part(cls, part: str, base: PlandoOptions) -> PlandoOptions:
|
||||
try:
|
||||
part = 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
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.value:
|
||||
return ", ".join(flag.name for flag in PlandoOptions if self.value & flag.value)
|
||||
return "None"
|
||||
|
||||
|
||||
seeddigits = 20
|
||||
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
|
||||
def _cmd_received(self) -> bool:
|
||||
"""List all received items"""
|
||||
logger.info(f'{len(self.ctx.items_received)} received items:')
|
||||
self.output(f'{len(self.ctx.items_received)} received items:')
|
||||
for index, item in enumerate(self.ctx.items_received, 1):
|
||||
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
|
||||
return True
|
||||
@@ -134,6 +134,7 @@ class CommonContext:
|
||||
tags: typing.Set[str] = {"AP"}
|
||||
game: typing.Optional[str] = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
want_slot_data: bool = True # should slot_data be retrieved via Connect
|
||||
|
||||
# datapackage
|
||||
# Contents in flux until connection to server is made, to download correct data for this multiworld.
|
||||
@@ -192,7 +193,7 @@ class CommonContext:
|
||||
self.hint_cost = None
|
||||
self.slot_info = {}
|
||||
self.permissions = {
|
||||
"forfeit": "disabled",
|
||||
"release": "disabled",
|
||||
"collect": "disabled",
|
||||
"remaining": "disabled",
|
||||
}
|
||||
@@ -259,7 +260,7 @@ class CommonContext:
|
||||
self.server_task = None
|
||||
self.hint_cost = None
|
||||
self.permissions = {
|
||||
"forfeit": "disabled",
|
||||
"release": "disabled",
|
||||
"collect": "disabled",
|
||||
"remaining": "disabled",
|
||||
}
|
||||
@@ -309,7 +310,7 @@ class CommonContext:
|
||||
'cmd': 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
'tags': self.tags, 'items_handling': self.items_handling,
|
||||
'uuid': Utils.get_unique_identifier(), 'game': self.game
|
||||
'uuid': Utils.get_unique_identifier(), 'game': self.game, "slot_data": self.want_slot_data,
|
||||
}
|
||||
if kwargs:
|
||||
payload.update(kwargs)
|
||||
@@ -340,6 +341,11 @@ class CommonContext:
|
||||
return self.slot in self.slot_info[slot].group_members
|
||||
return False
|
||||
|
||||
def is_echoed_chat(self, print_json_packet: dict) -> bool:
|
||||
return print_json_packet.get("type", "") == "Chat" \
|
||||
and print_json_packet.get("team", None) == self.team \
|
||||
and print_json_packet.get("slot", None) == self.slot
|
||||
|
||||
def is_uninteresting_item_send(self, print_json_packet: dict) -> bool:
|
||||
"""Helper function for filtering out ItemSend prints that do not concern the local player."""
|
||||
return print_json_packet.get("type", "") == "ItemSend" \
|
||||
@@ -493,7 +499,7 @@ class CommonContext:
|
||||
self._messagebox.open()
|
||||
return self._messagebox
|
||||
|
||||
def _handle_connection_loss(self, msg: str) -> None:
|
||||
def handle_connection_loss(self, msg: str) -> None:
|
||||
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
|
||||
exc_info = sys.exc_info()
|
||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||
@@ -579,14 +585,22 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
||||
for msg in decode(data):
|
||||
await process_server_cmd(ctx, msg)
|
||||
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}")
|
||||
except websockets.InvalidMessage:
|
||||
# probably encrypted
|
||||
if address.startswith("ws://"):
|
||||
await server_loop(ctx, "ws" + address[1:])
|
||||
else:
|
||||
ctx.handle_connection_loss(f"Lost connection to the multiworld server due to InvalidMessage"
|
||||
f"{reconnect_hint()}")
|
||||
except ConnectionRefusedError:
|
||||
ctx._handle_connection_loss("Connection refused by the server. May not be running Archipelago on that address or port.")
|
||||
ctx.handle_connection_loss("Connection refused by the server. "
|
||||
"May not be running Archipelago on that address or port.")
|
||||
except websockets.InvalidURI:
|
||||
ctx._handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
|
||||
ctx.handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
|
||||
except OSError:
|
||||
ctx._handle_connection_loss("Failed to connect to the multiworld server")
|
||||
ctx.handle_connection_loss("Failed to connect to the multiworld server")
|
||||
except Exception:
|
||||
ctx._handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
|
||||
ctx.handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
|
||||
finally:
|
||||
await ctx.connection_closed()
|
||||
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally:
|
||||
@@ -798,9 +812,10 @@ if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
|
||||
class TextContext(CommonContext):
|
||||
tags = {"AP", "IgnoreGame", "TextOnly"}
|
||||
tags = {"AP", "TextOnly"}
|
||||
game = "" # empty matches any game since 0.3.2
|
||||
items_handling = 0b111 # receive all items for /received
|
||||
want_slot_data = False # Can't use game specific slot_data
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -811,6 +826,10 @@ 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):
|
||||
|
||||
@@ -109,9 +109,10 @@ class FactorioContext(CommonContext):
|
||||
|
||||
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):
|
||||
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] + ":"):
|
||||
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)
|
||||
|
||||
|
||||
51
Fill.py
51
Fill.py
@@ -24,7 +24,8 @@ 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,
|
||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None) -> None:
|
||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
||||
allow_partial: bool = False) -> None:
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
|
||||
@@ -132,7 +133,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
||||
if on_place:
|
||||
on_place(spot_to_fill)
|
||||
|
||||
if len(unplaced_items) > 0 and len(locations) > 0:
|
||||
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0:
|
||||
# There are leftover unplaceable items and locations that won't accept them
|
||||
if world.can_beat_game():
|
||||
logging.warning(
|
||||
@@ -252,11 +253,12 @@ def distribute_early_items(world: MultiWorld,
|
||||
fill_locations: typing.List[Location],
|
||||
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
|
||||
""" returns new fill_locations and itempool """
|
||||
early_items_count: typing.Dict[typing.Tuple[str, int], int] = {}
|
||||
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
|
||||
for player in world.player_ids:
|
||||
items = itertools.chain(world.early_items[player], world.local_early_items[player])
|
||||
for item in items:
|
||||
early_items_count[(item, player)] = [world.early_items[player].get(item, 0), world.local_early_items[player].get(item, 0)]
|
||||
early_items_count[item, player] = [world.early_items[player].get(item, 0),
|
||||
world.local_early_items[player].get(item, 0)]
|
||||
if early_items_count:
|
||||
early_locations: typing.List[Location] = []
|
||||
early_priority_locations: typing.List[Location] = []
|
||||
@@ -280,42 +282,50 @@ def distribute_early_items(world: MultiWorld,
|
||||
for i, item in enumerate(itempool):
|
||||
if (item.name, item.player) in early_items_count:
|
||||
if item.advancement:
|
||||
if early_items_count[(item.name, item.player)][1]:
|
||||
if early_items_count[item.name, item.player][1]:
|
||||
early_local_prog_items[item.player].append(item)
|
||||
early_items_count[(item.name, item.player)][1] -= 1
|
||||
early_items_count[item.name, item.player][1] -= 1
|
||||
else:
|
||||
early_prog_items.append(item)
|
||||
early_items_count[(item.name, item.player)][0] -= 1
|
||||
early_items_count[item.name, item.player][0] -= 1
|
||||
else:
|
||||
if early_items_count[(item.name, item.player)][1]:
|
||||
if early_items_count[item.name, item.player][1]:
|
||||
early_local_rest_items[item.player].append(item)
|
||||
early_items_count[(item.name, item.player)][1] -= 1
|
||||
early_items_count[item.name, item.player][1] -= 1
|
||||
else:
|
||||
early_rest_items.append(item)
|
||||
early_items_count[(item.name, item.player)][0] -= 1
|
||||
early_items_count[item.name, item.player][0] -= 1
|
||||
item_indexes_to_remove.add(i)
|
||||
if early_items_count[(item.name, item.player)] == [0, 0]:
|
||||
del early_items_count[(item.name, item.player)]
|
||||
if early_items_count[item.name, item.player] == [0, 0]:
|
||||
del early_items_count[item.name, item.player]
|
||||
if len(early_items_count) == 0:
|
||||
break
|
||||
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
|
||||
for player in world.player_ids:
|
||||
player_local = early_local_rest_items[player]
|
||||
fill_restrictive(world, base_state,
|
||||
[loc for loc in early_locations if loc.player == player],
|
||||
early_local_rest_items[player], lock=True)
|
||||
[loc for loc in early_locations if loc.player == player],
|
||||
player_local, lock=True, allow_partial=True)
|
||||
if player_local:
|
||||
logging.warning(f"Could not fulfill rules of early items: {player_local}")
|
||||
early_rest_items.extend(early_local_rest_items[player])
|
||||
early_locations = [loc for loc in early_locations if not loc.item]
|
||||
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True)
|
||||
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True)
|
||||
early_locations += early_priority_locations
|
||||
for player in world.player_ids:
|
||||
player_local = early_local_prog_items[player]
|
||||
fill_restrictive(world, base_state,
|
||||
[loc for loc in early_locations if loc.player == player],
|
||||
early_local_prog_items[player], lock=True)
|
||||
[loc for loc in early_locations if loc.player == player],
|
||||
player_local, lock=True, allow_partial=True)
|
||||
if player_local:
|
||||
logging.warning(f"Could not fulfill rules of early items: {player_local}")
|
||||
early_prog_items.extend(player_local)
|
||||
early_locations = [loc for loc in early_locations if not loc.item]
|
||||
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True)
|
||||
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True)
|
||||
unplaced_early_items = early_rest_items + early_prog_items
|
||||
if unplaced_early_items:
|
||||
logging.warning("Ran out of early locations for early items. Failed to place "
|
||||
f"{len(unplaced_early_items)} items early.")
|
||||
f"{unplaced_early_items} early.")
|
||||
itempool += unplaced_early_items
|
||||
|
||||
fill_locations.extend(early_locations)
|
||||
@@ -830,8 +840,7 @@ def distribute_planned(world: MultiWorld) -> None:
|
||||
maxcount = placement['count']['target']
|
||||
from_pool = placement['from_pool']
|
||||
|
||||
candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
|
||||
worlds))
|
||||
candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
|
||||
world.random.shuffle(candidates)
|
||||
world.random.shuffle(items)
|
||||
count = 0
|
||||
|
||||
74
Generate.py
74
Generate.py
@@ -2,14 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import Set, Dict, Tuple, Callable, Any, Union
|
||||
import os
|
||||
from collections import Counter, ChainMap
|
||||
import random
|
||||
import string
|
||||
import enum
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter, ChainMap
|
||||
from typing import Dict, Tuple, Callable, Any, Union
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
@@ -18,52 +17,17 @@ ModuleUpdate.update()
|
||||
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, local_path, user_path
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, user_path
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
import Options
|
||||
from worlds.alttp.Text import TextTable
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import copy
|
||||
|
||||
|
||||
class PlandoSettings(enum.IntFlag):
|
||||
items = 0b0001
|
||||
connections = 0b0010
|
||||
texts = 0b0100
|
||||
bosses = 0b1000
|
||||
|
||||
@classmethod
|
||||
def from_option_string(cls, option_string: str) -> PlandoSettings:
|
||||
result = cls(0)
|
||||
for part in option_string.split(","):
|
||||
part = part.strip().lower()
|
||||
if part:
|
||||
result = cls._handle_part(part, result)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_set(cls, option_set: Set[str]) -> PlandoSettings:
|
||||
result = cls(0)
|
||||
for part in option_set:
|
||||
result = cls._handle_part(part, result)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _handle_part(cls, part: str, base: PlandoSettings) -> PlandoSettings:
|
||||
try:
|
||||
part = 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
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.value:
|
||||
return ", ".join(flag.name for flag in PlandoSettings if self.value & flag.value)
|
||||
return "Off"
|
||||
|
||||
|
||||
def mystery_argparse():
|
||||
@@ -97,7 +61,7 @@ def mystery_argparse():
|
||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||
if not os.path.isabs(args.meta_file_path):
|
||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||
args.plando: PlandoSettings = PlandoSettings.from_option_string(args.plando)
|
||||
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||
return args, options
|
||||
|
||||
|
||||
@@ -143,7 +107,7 @@ def main(args=None, callback=ERmain):
|
||||
player_files = {}
|
||||
for file in os.scandir(args.player_files_path):
|
||||
fname = file.name
|
||||
if file.is_file() and not file.name.startswith(".") and \
|
||||
if file.is_file() and not fname.startswith(".") and \
|
||||
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||
path = os.path.join(args.player_files_path, fname)
|
||||
try:
|
||||
@@ -170,6 +134,7 @@ def main(args=None, callback=ERmain):
|
||||
f"A mix is also permitted.")
|
||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||
erargs.seed = seed
|
||||
erargs.plando_options = args.plando
|
||||
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
|
||||
erargs.spoiler = args.spoiler
|
||||
erargs.race = args.race
|
||||
@@ -226,7 +191,7 @@ def main(args=None, callback=ERmain):
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
|
||||
player += 1
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||
@@ -443,7 +408,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||
return weights
|
||||
|
||||
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoSettings):
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
||||
if option_key in game_weights:
|
||||
try:
|
||||
if not option.supports_weighting:
|
||||
@@ -459,7 +424,7 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
||||
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
|
||||
|
||||
|
||||
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
|
||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
@@ -472,7 +437,7 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
|
||||
if tuplize_version(version) > version_tuple:
|
||||
raise Exception(f"Settings reports required version of generator is at least {version}, "
|
||||
f"however generator is of version {__version__}")
|
||||
required_plando_options = PlandoSettings.from_option_string(requirements.get("plando", ""))
|
||||
required_plando_options = PlandoOptions.from_option_string(requirements.get("plando", ""))
|
||||
if required_plando_options not in plando_options:
|
||||
if required_plando_options:
|
||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||
@@ -503,14 +468,15 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
|
||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||
for option_key, option in Options.per_game_common_options.items():
|
||||
# skip setting this option if already set from common_options, defaulting to root option
|
||||
if not (option_key in Options.common_options and option_key not in game_weights):
|
||||
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 PlandoSettings.items in 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 PlandoSettings.connections in plando_options:
|
||||
if PlandoOptions.connections in plando_options:
|
||||
options = game_weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
@@ -625,7 +591,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||
|
||||
ret.plando_texts = {}
|
||||
if PlandoSettings.texts in plando_options:
|
||||
if PlandoOptions.texts in plando_options:
|
||||
tt = TextTable()
|
||||
tt.removeUnwantedText()
|
||||
options = weights.get("plando_texts", [])
|
||||
@@ -637,7 +603,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
||||
|
||||
ret.plando_connections = []
|
||||
if PlandoSettings.connections in plando_options:
|
||||
if PlandoOptions.connections in plando_options:
|
||||
options = weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
|
||||
@@ -132,7 +132,8 @@ components: Iterable[Component] = (
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
|
||||
# SNI
|
||||
Component('SNI Client', 'SNIClient',
|
||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw')),
|
||||
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3',
|
||||
'.apsmw', '.apl2ac')),
|
||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||
# Factorio
|
||||
Component('Factorio Client', 'FactorioClient'),
|
||||
@@ -147,10 +148,14 @@ components: Iterable[Component] = (
|
||||
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')),
|
||||
|
||||
@@ -35,7 +35,7 @@ class AdjusterWorld(object):
|
||||
def __init__(self, sprite_pool):
|
||||
import random
|
||||
self.sprite_pool = {1: sprite_pool}
|
||||
self.slot_seeds = {1: random}
|
||||
self.per_slot_randoms = {1: random}
|
||||
|
||||
|
||||
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
|
||||
242
Main.py
242
Main.py
@@ -1,5 +1,4 @@
|
||||
import collections
|
||||
from itertools import zip_longest, chain
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
@@ -10,11 +9,12 @@ import tempfile
|
||||
import zipfile
|
||||
from typing import Dict, List, Tuple, Optional, Set
|
||||
|
||||
from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
||||
from worlds.alttp.Items import item_name_groups
|
||||
from BaseClasses import Item, MultiWorld, CollectionState, Region, LocationProgressType, Location
|
||||
import worlds
|
||||
from worlds.alttp.SubClasses import LTTPRegionType
|
||||
from worlds.alttp.Regions import is_main_entrance
|
||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||
from worlds.alttp.Shops import FillDisabledShopSlots
|
||||
from Utils import output_path, get_options, __version__, version_tuple
|
||||
from worlds.generic.Rules import locality_rules, exclusion_rules
|
||||
from worlds import AutoWorld
|
||||
@@ -38,7 +38,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
world = MultiWorld(args.multi)
|
||||
|
||||
logger = logging.getLogger()
|
||||
world.set_seed(seed, args.race, str(args.outputname if args.outputname else world.seed))
|
||||
world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
|
||||
world.plando_options = args.plando_options
|
||||
|
||||
world.shuffle = args.shuffle.copy()
|
||||
world.logic = args.logic.copy()
|
||||
@@ -52,7 +53,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
world.enemy_damage = args.enemy_damage.copy()
|
||||
world.beemizer_total_chance = args.beemizer_total_chance.copy()
|
||||
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
|
||||
world.timer = args.timer.copy()
|
||||
world.countdown_start_time = args.countdown_start_time.copy()
|
||||
world.red_clock_time = args.red_clock_time.copy()
|
||||
world.blue_clock_time = args.blue_clock_time.copy()
|
||||
@@ -78,7 +78,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
world.state = CollectionState(world)
|
||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
|
||||
|
||||
logger.info("Found World Types:")
|
||||
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
|
||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||
|
||||
max_item = 0
|
||||
@@ -116,26 +116,23 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
for _ in range(count):
|
||||
world.push_precollected(world.create_item(item_name, player))
|
||||
|
||||
for player in world.player_ids:
|
||||
if player in world.get_game_players("A Link to the Past"):
|
||||
# enforce pre-defined local items.
|
||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||
world.local_items[player].value.add('Triforce Piece')
|
||||
|
||||
# Not possible to place pendants/crystals outside boss prizes yet.
|
||||
world.non_local_items[player].value -= item_name_groups['Pendants']
|
||||
world.non_local_items[player].value -= item_name_groups['Crystals']
|
||||
|
||||
# items can't be both local and non-local, prefer local
|
||||
world.non_local_items[player].value -= world.local_items[player].value
|
||||
|
||||
logger.info('Creating World.')
|
||||
AutoWorld.call_all(world, "create_regions")
|
||||
|
||||
logger.info('Creating Items.')
|
||||
AutoWorld.call_all(world, "create_items")
|
||||
|
||||
# All worlds should have finished creating all regions, locations, and entrances.
|
||||
# Recache to ensure that they are all visible for locality rules.
|
||||
world._recache()
|
||||
|
||||
logger.info('Calculating Access Rules.')
|
||||
|
||||
for player in world.player_ids:
|
||||
# items can't be both local and non-local, prefer local
|
||||
world.non_local_items[player].value -= world.local_items[player].value
|
||||
world.non_local_items[player].value -= set(world.local_early_items[player])
|
||||
|
||||
if world.players > 1:
|
||||
locality_rules(world)
|
||||
else:
|
||||
@@ -194,7 +191,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
new_item.classification |= classifications[item_name]
|
||||
new_itempool.append(new_item)
|
||||
|
||||
region = Region("Menu", RegionType.Generic, "ItemLink", group_id, world)
|
||||
region = Region("Menu", group_id, world, "ItemLink")
|
||||
world.regions.append(region)
|
||||
locations = region.locations = []
|
||||
for item in world.itempool:
|
||||
@@ -217,11 +214,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
while itemcount > len(world.itempool):
|
||||
items_to_add = []
|
||||
for player in group["players"]:
|
||||
if group["link_replacement"]:
|
||||
item_player = group_id
|
||||
else:
|
||||
item_player = player
|
||||
if group["replacement_items"][player]:
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_item", player,
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_item", item_player,
|
||||
group["replacement_items"][player]))
|
||||
else:
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_filler", player))
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_filler", item_player))
|
||||
world.random.shuffle(items_to_add)
|
||||
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
|
||||
|
||||
@@ -250,6 +251,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
balance_multiworld_progression(world)
|
||||
|
||||
logger.info(f'Beginning output...')
|
||||
|
||||
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
|
||||
world.random.passthrough = False
|
||||
|
||||
outfilebase = 'AP_' + world.seed_name
|
||||
|
||||
output = tempfile.TemporaryDirectory()
|
||||
@@ -285,37 +290,16 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
'Inverted Ganons Tower': 'Ganons Tower'} \
|
||||
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||
checks_in_area[location.player][dungeonname].append(location.address)
|
||||
elif location.parent_region.type == RegionType.LightWorld:
|
||||
elif location.parent_region.type == LTTPRegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif location.parent_region.type == RegionType.DarkWorld:
|
||||
elif location.parent_region.type == LTTPRegionType.DarkWorld:
|
||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
elif main_entrance.parent_region.type == LTTPRegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
||||
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
|
||||
|
||||
oldmancaves = []
|
||||
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
|
||||
for index, take_any in enumerate(takeanyregions):
|
||||
for region in [world.get_region(take_any, player) for player in
|
||||
world.get_game_players("A Link to the Past") if world.retro_caves[player]]:
|
||||
item = world.create_item(
|
||||
region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
|
||||
region.player)
|
||||
player = region.player
|
||||
location_id = SHOP_ID_START + total_shop_slots + index
|
||||
|
||||
main_entrance = region.get_connecting_entrance(is_main_entrance)
|
||||
if main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[player]["Light World"].append(location_id)
|
||||
else:
|
||||
checks_in_area[player]["Dark World"].append(location_id)
|
||||
checks_in_area[player]["Total"] += 1
|
||||
|
||||
er_hint_data[player][location_id] = main_entrance.name
|
||||
oldmancaves.append(((location_id, player), (item.code, player)))
|
||||
|
||||
FillDisabledShopSlots(world)
|
||||
|
||||
def write_multidata():
|
||||
@@ -332,7 +316,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])
|
||||
world.player_types[slot], bool(world.allow_collect[slot].value))
|
||||
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],
|
||||
@@ -371,16 +355,19 @@ 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
|
||||
|
||||
multidata = {
|
||||
"slot_data": slot_data,
|
||||
"slot_info": slot_info,
|
||||
"names": names, # TODO: remove around 0.2.5 in favor of slot_info
|
||||
"games": games, # TODO: remove around 0.2.5 in favor of slot_info
|
||||
"names": names, # TODO: remove after 0.3.9
|
||||
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
|
||||
"remote_items": {player for player in world.player_ids if
|
||||
world.worlds[player].remote_items},
|
||||
"remote_start_inventory": {player for player in world.player_ids if
|
||||
world.worlds[player].remote_start_inventory},
|
||||
"locations": locations_data,
|
||||
"checks_in_area": checks_in_area,
|
||||
"server_options": baked_server_options,
|
||||
@@ -390,7 +377,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
"version": tuple(version_tuple),
|
||||
"tags": ["AP"],
|
||||
"minimum_versions": minimum_versions,
|
||||
"seed_name": world.seed_name
|
||||
"seed_name": world.seed_name,
|
||||
"datapackage": datapackage,
|
||||
}
|
||||
AutoWorld.call_all(world, "modify_multidata", multidata)
|
||||
|
||||
@@ -416,7 +404,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
if args.spoiler > 1:
|
||||
logger.info('Calculating playthrough.')
|
||||
create_playthrough(world)
|
||||
world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
||||
|
||||
if args.spoiler:
|
||||
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
||||
@@ -430,143 +418,3 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
|
||||
return world
|
||||
|
||||
|
||||
def create_playthrough(world):
|
||||
"""Destructive to the world while it is run, damage gets repaired afterwards."""
|
||||
# get locations containing progress items
|
||||
prog_locations = {location for location in world.get_filled_locations() if location.item.advancement}
|
||||
state_cache = [None]
|
||||
collection_spheres = []
|
||||
state = CollectionState(world)
|
||||
sphere_candidates = set(prog_locations)
|
||||
logging.debug('Building up collection spheres.')
|
||||
while sphere_candidates:
|
||||
|
||||
# build up spheres of collection radius.
|
||||
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
||||
|
||||
sphere = {location for location in sphere_candidates if state.can_reach(location)}
|
||||
|
||||
for location in sphere:
|
||||
state.collect(location.item, True, location)
|
||||
|
||||
sphere_candidates -= sphere
|
||||
collection_spheres.append(sphere)
|
||||
state_cache.append(state.copy())
|
||||
|
||||
logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere),
|
||||
len(prog_locations))
|
||||
if not sphere:
|
||||
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
||||
location.item.name, location.item.player, location.name, location.player) for location in
|
||||
sphere_candidates])
|
||||
if any([world.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
|
||||
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
||||
f'Something went terribly wrong here.')
|
||||
else:
|
||||
world.spoiler.unreachables = sphere_candidates
|
||||
break
|
||||
|
||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||
# reducing each range of influence to the bare minimum required inside it
|
||||
restore_later = {}
|
||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||
to_delete = set()
|
||||
for location in sphere:
|
||||
# we remove the item at location and check if game is still beatable
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||
location.item.player)
|
||||
old_item = location.item
|
||||
location.item = None
|
||||
if world.can_beat_game(state_cache[num]):
|
||||
to_delete.add(location)
|
||||
restore_later[location] = old_item
|
||||
else:
|
||||
# still required, got to keep it around
|
||||
location.item = old_item
|
||||
|
||||
# cull entries in spheres for spoiler walkthrough at end
|
||||
sphere -= to_delete
|
||||
|
||||
# second phase, sphere 0
|
||||
removed_precollected = []
|
||||
for item in (i for i in chain.from_iterable(world.precollected_items.values()) if i.advancement):
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
world.precollected_items[item.player].remove(item)
|
||||
world.state.remove(item)
|
||||
if not world.can_beat_game():
|
||||
world.push_precollected(item)
|
||||
else:
|
||||
removed_precollected.append(item)
|
||||
|
||||
# we are now down to just the required progress items in collection_spheres. Unfortunately
|
||||
# the previous pruning stage could potentially have made certain items dependant on others
|
||||
# in the same or later sphere (because the location had 2 ways to access but the item originally
|
||||
# used to access it was deemed not required.) So we need to do one final sphere collection pass
|
||||
# to build up the correct spheres
|
||||
|
||||
required_locations = {item for sphere in collection_spheres for item in sphere}
|
||||
state = CollectionState(world)
|
||||
collection_spheres = []
|
||||
while required_locations:
|
||||
state.sweep_for_events(key_only=True)
|
||||
|
||||
sphere = set(filter(state.can_reach, required_locations))
|
||||
|
||||
for location in sphere:
|
||||
state.collect(location.item, True, location)
|
||||
|
||||
required_locations -= sphere
|
||||
|
||||
collection_spheres.append(sphere)
|
||||
|
||||
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
|
||||
len(sphere), len(required_locations))
|
||||
if not sphere:
|
||||
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
|
||||
|
||||
def flist_to_iter(node):
|
||||
while node:
|
||||
value, node = node
|
||||
yield value
|
||||
|
||||
def get_path(state, region):
|
||||
reversed_path_as_flist = state.path.get(region, (region, None))
|
||||
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
|
||||
# Now we combine the flat string list into (region, exit) pairs
|
||||
pathsiter = iter(string_path_flat)
|
||||
pathpairs = zip_longest(pathsiter, pathsiter)
|
||||
return list(pathpairs)
|
||||
|
||||
world.spoiler.paths = {}
|
||||
topology_worlds = (player for player in world.player_ids if world.worlds[player].topology_present)
|
||||
for player in topology_worlds:
|
||||
world.spoiler.paths.update(
|
||||
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
|
||||
sphere if location.player == player})
|
||||
if player in world.get_game_players("A Link to the Past"):
|
||||
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
|
||||
# Maybe move the big bomb over to the Event system instead?
|
||||
if any(exit_path == 'Pyramid Fairy' for path in world.spoiler.paths.values() for (_, exit_path) in path):
|
||||
if world.mode[player] != 'inverted':
|
||||
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = \
|
||||
get_path(state, world.get_region('Big Bomb Shop', player))
|
||||
else:
|
||||
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = \
|
||||
get_path(state, world.get_region('Inverted Big Bomb Shop', player))
|
||||
|
||||
# we can finally output our playthrough
|
||||
world.spoiler.playthrough = {"0": sorted([str(item) for item in
|
||||
chain.from_iterable(world.precollected_items.values())
|
||||
if item.advancement])}
|
||||
|
||||
for i, sphere in enumerate(collection_spheres):
|
||||
world.spoiler.playthrough[str(i + 1)] = {str(location): str(location.item) for location in sorted(sphere)}
|
||||
|
||||
# repair the world again
|
||||
for location, item in restore_later.items():
|
||||
location.item = item
|
||||
|
||||
for item in removed_precollected:
|
||||
world.push_precollected(item)
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import sys
|
||||
import subprocess
|
||||
import pkg_resources
|
||||
import warnings
|
||||
|
||||
local_dir = os.path.dirname(__file__)
|
||||
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
|
||||
@@ -39,6 +40,8 @@ def update(yes=False, force=False):
|
||||
path = os.path.join(os.path.dirname(__file__), req_file)
|
||||
with open(path) as requirementsfile:
|
||||
for line in requirementsfile:
|
||||
if not line or line[0] == "#":
|
||||
continue # ignore comments
|
||||
if line.startswith(("https://", "git+https://")):
|
||||
# extract name and version for url
|
||||
rest = line.split('/')[-1]
|
||||
@@ -46,8 +49,10 @@ def update(yes=False, force=False):
|
||||
if "#egg=" in rest:
|
||||
# from egg info
|
||||
rest, egg = rest.split("#egg=", 1)
|
||||
egg = egg.split(";", 1)[0]
|
||||
egg = egg.split(";", 1)[0].rstrip()
|
||||
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
|
||||
warnings.warn(f"Specifying version as #egg={egg} will become unavailable in pip 25.0. "
|
||||
"Use name @ url#version instead.", DeprecationWarning)
|
||||
line = egg
|
||||
else:
|
||||
egg = ""
|
||||
@@ -58,16 +63,27 @@ def update(yes=False, force=False):
|
||||
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
|
||||
name, version, _ = rest.split("-", 2)
|
||||
line = f'{egg or name}=={version}'
|
||||
elif "@" in line and "#" in line:
|
||||
# PEP 508 does not allow us to specify a version, so we use custom syntax
|
||||
# name @ url#version ; marker
|
||||
name, rest = line.split("@", 1)
|
||||
version = rest.split("#", 1)[1].split(";", 1)[0].rstrip()
|
||||
line = f"{name.rstrip()}=={version}"
|
||||
if ";" in rest: # keep marker
|
||||
line += rest[rest.find(";"):]
|
||||
requirements = pkg_resources.parse_requirements(line)
|
||||
for requirement in requirements:
|
||||
requirement = str(requirement)
|
||||
for requirement in map(str, requirements):
|
||||
try:
|
||||
pkg_resources.require(requirement)
|
||||
except pkg_resources.ResolutionError:
|
||||
if not yes:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
input(f'Requirement {requirement} is not satisfied, press enter to install it')
|
||||
try:
|
||||
input(f"\nRequirement {requirement} is not satisfied, press enter to install it")
|
||||
except KeyboardInterrupt:
|
||||
print("\nAborting")
|
||||
sys.exit(1)
|
||||
update_command()
|
||||
return
|
||||
|
||||
|
||||
556
MultiServer.py
556
MultiServer.py
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ class Permission(enum.IntFlag):
|
||||
disabled = 0b000 # 0, completely disables access
|
||||
enabled = 0b001 # 1, allows manual use
|
||||
goal = 0b010 # 2, allows manual use after goal completion
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for forfeit
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for release
|
||||
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
|
||||
|
||||
@staticmethod
|
||||
@@ -71,6 +71,7 @@ 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
|
||||
|
||||
|
||||
@@ -86,7 +87,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
data = obj._asdict()
|
||||
data["class"] = obj.__class__.__name__
|
||||
return data
|
||||
if isinstance(obj, (tuple, list, set)):
|
||||
if isinstance(obj, (tuple, list, set, frozenset)):
|
||||
return tuple(_scan_for_TypedTuples(o) for o in obj)
|
||||
if isinstance(obj, dict):
|
||||
return {key: _scan_for_TypedTuples(value) for key, value in obj.items()}
|
||||
@@ -109,7 +110,7 @@ def get_any_version(data: dict) -> Version:
|
||||
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
|
||||
|
||||
|
||||
whitelist = {
|
||||
allowlist = {
|
||||
"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
"NetworkSlot": NetworkSlot
|
||||
@@ -125,7 +126,7 @@ def _object_hook(o: typing.Any) -> typing.Any:
|
||||
hook = custom_hooks.get(o.get("class", None), None)
|
||||
if hook:
|
||||
return hook(o)
|
||||
cls = whitelist.get(o.get("class", None), None)
|
||||
cls = allowlist.get(o.get("class", None), None)
|
||||
if cls:
|
||||
for key in tuple(o):
|
||||
if key not in cls._fields:
|
||||
|
||||
@@ -3,6 +3,7 @@ import argparse
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
import zipfile
|
||||
from itertools import chain
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
@@ -196,7 +197,7 @@ def set_icon(window):
|
||||
def adjust(args):
|
||||
# Create a fake world and OOTWorld to use as a base
|
||||
world = MultiWorld(1)
|
||||
world.slot_seeds = {1: random}
|
||||
world.per_slot_randoms = {1: random}
|
||||
ootworld = OOTWorld(world, 1)
|
||||
# Set options in the fake OOTWorld
|
||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||
@@ -217,13 +218,18 @@ def adjust(args):
|
||||
# Load up the ROM
|
||||
rom = Rom(file=args.rom, force_use=True)
|
||||
delete_zootdec = True
|
||||
elif os.path.splitext(args.rom)[-1] == '.apz5':
|
||||
elif os.path.splitext(args.rom)[-1] in ['.apz5', '.zpf']:
|
||||
# Load vanilla ROM
|
||||
rom = Rom(file=args.vanilla_rom, force_use=True)
|
||||
apz5_file = args.rom
|
||||
base_name = os.path.splitext(apz5_file)[0]
|
||||
# Patch file
|
||||
apply_patch_file(rom, args.rom)
|
||||
apply_patch_file(rom, apz5_file,
|
||||
sub_file=(os.path.basename(base_name) + '.zpf'
|
||||
if zipfile.is_zipfile(apz5_file)
|
||||
else None))
|
||||
else:
|
||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
|
||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5, .zpf")
|
||||
# Call patch_cosmetics
|
||||
try:
|
||||
patch_cosmetics(ootworld, rom)
|
||||
|
||||
46
OoTClient.py
46
OoTClient.py
@@ -3,6 +3,7 @@ import json
|
||||
import os
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
import zipfile
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
@@ -50,7 +51,7 @@ deathlink_sent_this_death: we interacted with the multiworld on this death, wait
|
||||
|
||||
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
||||
|
||||
script_version: int = 2
|
||||
script_version: int = 3
|
||||
|
||||
def get_item_value(ap_id):
|
||||
return ap_id - 66000
|
||||
@@ -85,6 +86,9 @@ class OoTContext(CommonContext):
|
||||
self.n64_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.location_table = {}
|
||||
self.collectible_table = {}
|
||||
self.collectible_override_flags_address = 0
|
||||
self.collectible_offsets = {}
|
||||
self.deathlink_enabled = False
|
||||
self.deathlink_pending = False
|
||||
self.deathlink_sent_this_death = False
|
||||
@@ -117,6 +121,13 @@ class OoTContext(CommonContext):
|
||||
self.ui = OoTManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_package(self, cmd, args):
|
||||
if cmd == 'Connected':
|
||||
slot_data = args.get('slot_data', None)
|
||||
if slot_data:
|
||||
self.collectible_override_flags_address = slot_data.get('collectible_override_flags', 0)
|
||||
self.collectible_offsets = slot_data.get('collectible_flag_offsets', {})
|
||||
|
||||
|
||||
def get_payload(ctx: OoTContext):
|
||||
if ctx.deathlink_enabled and ctx.deathlink_pending:
|
||||
@@ -125,11 +136,14 @@ def get_payload(ctx: OoTContext):
|
||||
else:
|
||||
trigger_death = False
|
||||
|
||||
return json.dumps({
|
||||
payload = json.dumps({
|
||||
"items": [get_item_value(item.item) for item in ctx.items_received],
|
||||
"playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
|
||||
"triggerDeath": trigger_death
|
||||
"triggerDeath": trigger_death,
|
||||
"collectibleOverrides": ctx.collectible_override_flags_address,
|
||||
"collectibleOffsets": ctx.collectible_offsets
|
||||
})
|
||||
return payload
|
||||
|
||||
|
||||
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
@@ -141,6 +155,7 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
ctx.deathlink_client_override = False
|
||||
ctx.finished_game = False
|
||||
ctx.location_table = {}
|
||||
ctx.collectible_table = {}
|
||||
ctx.deathlink_pending = False
|
||||
ctx.deathlink_sent_this_death = False
|
||||
ctx.auth = payload['playerName']
|
||||
@@ -161,11 +176,17 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
ctx.finished_game = True
|
||||
|
||||
# Locations handling
|
||||
if ctx.location_table != payload['locations']:
|
||||
ctx.location_table = payload['locations']
|
||||
locations = payload['locations']
|
||||
collectibles = payload['collectibles']
|
||||
|
||||
if ctx.location_table != locations or ctx.collectible_table != collectibles:
|
||||
ctx.location_table = locations
|
||||
ctx.collectible_table = collectibles
|
||||
locs1 = [oot_loc_name_to_id[loc] for loc, b in ctx.location_table.items() if b]
|
||||
locs2 = [int(loc) for loc, b in ctx.collectible_table.items() if b]
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationChecks",
|
||||
"locations": [oot_loc_name_to_id[loc] for loc in ctx.location_table if ctx.location_table[loc]]
|
||||
"locations": locs1 + locs2
|
||||
}])
|
||||
|
||||
# Deathlink handling
|
||||
@@ -191,13 +212,6 @@ async def n64_sync_task(ctx: OoTContext):
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to six fields:
|
||||
# 1. str: player name (always)
|
||||
# 2. int: script version (always)
|
||||
# 3. bool: deathlink active (always)
|
||||
# 4. dict[str, bool]: checked locations
|
||||
# 5. bool: whether Link is currently at 0 HP
|
||||
# 6. 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)
|
||||
@@ -270,12 +284,16 @@ async def run_game(romfile):
|
||||
|
||||
|
||||
async def patch_and_run_game(apz5_file):
|
||||
apz5_file = os.path.abspath(apz5_file)
|
||||
base_name = os.path.splitext(apz5_file)[0]
|
||||
decomp_path = base_name + '-decomp.z64'
|
||||
comp_path = base_name + '.z64'
|
||||
# Load vanilla ROM, patch file, compress ROM
|
||||
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
|
||||
apply_patch_file(rom, apz5_file)
|
||||
apply_patch_file(rom, apz5_file,
|
||||
sub_file=(os.path.basename(base_name) + '.zpf'
|
||||
if zipfile.is_zipfile(apz5_file)
|
||||
else None))
|
||||
rom.write_to_file(decomp_path)
|
||||
os.chdir(data_path("Compress"))
|
||||
compress_rom_file(decomp_path, comp_path)
|
||||
|
||||
99
Options.py
99
Options.py
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
import abc
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
import math
|
||||
import numbers
|
||||
@@ -9,6 +10,10 @@ import random
|
||||
from schema import Schema, And, Or, Optional
|
||||
from Utils import get_fuzzy_results
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from BaseClasses import PlandoOptions
|
||||
from worlds.AutoWorld import World
|
||||
|
||||
|
||||
class AssembleOptions(abc.ABCMeta):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
@@ -79,9 +84,6 @@ class AssembleOptions(abc.ABCMeta):
|
||||
|
||||
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
||||
|
||||
@abc.abstractclassmethod
|
||||
def from_any(cls, value: typing.Any) -> "Option[typing.Any]": ...
|
||||
|
||||
|
||||
T = typing.TypeVar('T')
|
||||
|
||||
@@ -98,11 +100,11 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||
supports_weighting = True
|
||||
|
||||
# filled by AssembleOptions:
|
||||
name_lookup: typing.Dict[int, str]
|
||||
name_lookup: typing.Dict[T, str]
|
||||
options: typing.Dict[str, int]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.get_current_option_name()})"
|
||||
return f"{self.__class__.__name__}({self.current_option_name})"
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.value)
|
||||
@@ -112,7 +114,14 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||
return self.name_lookup[self.value]
|
||||
|
||||
def get_current_option_name(self) -> str:
|
||||
"""For display purposes."""
|
||||
"""Deprecated. use current_option_name instead. TODO remove around 0.4"""
|
||||
logging.warning(DeprecationWarning(f"get_current_option_name for {self.__class__.__name__} is deprecated."
|
||||
f" use current_option_name instead. Worlds should use {self}.current_key"))
|
||||
return self.current_option_name
|
||||
|
||||
@property
|
||||
def current_option_name(self) -> str:
|
||||
"""For display purposes. Worlds should be using current_key."""
|
||||
return self.get_option_name(self.value)
|
||||
|
||||
@classmethod
|
||||
@@ -129,21 +138,19 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||
return bool(self.value)
|
||||
|
||||
@classmethod
|
||||
@abc.abstractmethod
|
||||
def from_any(cls, data: typing.Any) -> Option[T]:
|
||||
raise NotImplementedError
|
||||
...
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from Generate import PlandoSettings
|
||||
from worlds.AutoWorld import World
|
||||
|
||||
def verify(self, world: World, player_name: str, plando_options: PlandoSettings) -> None:
|
||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
|
||||
pass
|
||||
else:
|
||||
def verify(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class FreeText(Option):
|
||||
class FreeText(Option[str]):
|
||||
"""Text option that allows users to enter strings.
|
||||
Needs to be validated by the world or option definition."""
|
||||
|
||||
@@ -164,11 +171,11 @@ class FreeText(Option):
|
||||
return cls.from_text(str(data))
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value: T) -> str:
|
||||
def get_option_name(cls, value: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
class NumericOption(Option[int], numbers.Integral):
|
||||
class NumericOption(Option[int], numbers.Integral, abc.ABC):
|
||||
default = 0
|
||||
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
||||
# `int` is not a `numbers.Integral` according to the official typestubs
|
||||
@@ -426,6 +433,7 @@ class Choice(NumericOption):
|
||||
|
||||
class TextChoice(Choice):
|
||||
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
|
||||
value: typing.Union[str, int]
|
||||
|
||||
def __init__(self, value: typing.Union[str, int]):
|
||||
assert isinstance(value, str) or isinstance(value, int), \
|
||||
@@ -436,8 +444,7 @@ class TextChoice(Choice):
|
||||
def current_key(self) -> str:
|
||||
if isinstance(self.value, str):
|
||||
return self.value
|
||||
else:
|
||||
return self.name_lookup[self.value]
|
||||
return super().current_key
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> TextChoice:
|
||||
@@ -452,7 +459,7 @@ class TextChoice(Choice):
|
||||
def get_option_name(cls, value: T) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return cls.name_lookup[value]
|
||||
return super().get_option_name(value)
|
||||
|
||||
def __eq__(self, other: typing.Any):
|
||||
if isinstance(other, self.__class__):
|
||||
@@ -575,12 +582,11 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
|
||||
def valid_location_name(cls, value: str) -> bool:
|
||||
return value in cls.locations
|
||||
|
||||
def verify(self, world, player_name: str, plando_options) -> None:
|
||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||
if isinstance(self.value, int):
|
||||
return
|
||||
from Generate import PlandoSettings
|
||||
if not(PlandoSettings.bosses & plando_options):
|
||||
import logging
|
||||
from BaseClasses import PlandoOptions
|
||||
if not(PlandoOptions.bosses & plando_options):
|
||||
# plando is disabled but plando options were given so pull the option and change it to an int
|
||||
option = self.value.split(";")[-1]
|
||||
self.value = self.options[option]
|
||||
@@ -718,7 +724,7 @@ class VerifyKeys:
|
||||
value: typing.Any
|
||||
|
||||
@classmethod
|
||||
def verify_keys(cls, data):
|
||||
def verify_keys(cls, data: typing.List[str]):
|
||||
if cls.valid_keys:
|
||||
data = set(data)
|
||||
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
|
||||
@@ -727,12 +733,17 @@ class VerifyKeys:
|
||||
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
|
||||
f"Allowed keys: {cls.valid_keys}.")
|
||||
|
||||
def verify(self, world, player_name: str, plando_options) -> None:
|
||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||
if self.convert_name_groups and self.verify_item_name:
|
||||
new_value = type(self.value)() # empty container of whatever value is
|
||||
for item_name in self.value:
|
||||
new_value |= world.item_name_groups.get(item_name, {item_name})
|
||||
self.value = new_value
|
||||
elif self.convert_name_groups and self.verify_location_name:
|
||||
new_value = type(self.value)()
|
||||
for loc_name in self.value:
|
||||
new_value |= world.location_name_groups.get(loc_name, {loc_name})
|
||||
self.value = new_value
|
||||
if self.verify_item_name:
|
||||
for item_name in self.value:
|
||||
if item_name not in world.item_names:
|
||||
@@ -832,7 +843,9 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
|
||||
return item in self.value
|
||||
|
||||
|
||||
local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
|
||||
class ItemSet(OptionSet):
|
||||
verify_item_name = True
|
||||
convert_name_groups = True
|
||||
|
||||
|
||||
class Accessibility(Choice):
|
||||
@@ -862,17 +875,20 @@ 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
|
||||
"accessibility": Accessibility,
|
||||
"allow_collect": AllowCollect
|
||||
}
|
||||
|
||||
|
||||
class ItemSet(OptionSet):
|
||||
verify_item_name = True
|
||||
convert_name_groups = True
|
||||
|
||||
|
||||
class LocalItems(ItemSet):
|
||||
"""Forces these items to be in their native world."""
|
||||
display_name = "Local Items"
|
||||
@@ -894,22 +910,23 @@ class StartHints(ItemSet):
|
||||
display_name = "Start Hints"
|
||||
|
||||
|
||||
class StartLocationHints(OptionSet):
|
||||
class LocationSet(OptionSet):
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class StartLocationHints(LocationSet):
|
||||
"""Start with these locations and their item prefilled into the !hint command"""
|
||||
display_name = "Start Location Hints"
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class ExcludeLocations(OptionSet):
|
||||
class ExcludeLocations(LocationSet):
|
||||
"""Prevent these locations from having an important item"""
|
||||
display_name = "Excluded Locations"
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class PriorityLocations(OptionSet):
|
||||
class PriorityLocations(LocationSet):
|
||||
"""Prevent these locations from having an unimportant item"""
|
||||
display_name = "Priority Locations"
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class DeathLink(Toggle):
|
||||
@@ -927,7 +944,8 @@ class ItemLinks(OptionList):
|
||||
Optional("exclude"): [And(str, len)],
|
||||
"replacement_item": Or(And(str, len), None),
|
||||
Optional("local_items"): [And(str, len)],
|
||||
Optional("non_local_items"): [And(str, len)]
|
||||
Optional("non_local_items"): [And(str, len)],
|
||||
Optional("link_replacement"): Or(None, bool),
|
||||
}
|
||||
])
|
||||
|
||||
@@ -949,7 +967,8 @@ class ItemLinks(OptionList):
|
||||
pool |= {item_name}
|
||||
return pool
|
||||
|
||||
def verify(self, world, player_name: str, plando_options) -> None:
|
||||
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
|
||||
link: dict
|
||||
super(ItemLinks, self).verify(world, player_name, plando_options)
|
||||
existing_links = set()
|
||||
for link in self.value:
|
||||
@@ -974,7 +993,9 @@ class ItemLinks(OptionList):
|
||||
|
||||
intersection = local_items.intersection(non_local_items)
|
||||
if intersection:
|
||||
raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.")
|
||||
raise Exception(f"item_link {link['name']} has {intersection} "
|
||||
f"items in both its local_items and non_local_items pool.")
|
||||
link.setdefault("link_replacement", None)
|
||||
|
||||
|
||||
per_game_common_options = {
|
||||
|
||||
@@ -15,8 +15,9 @@ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandP
|
||||
get_base_parser
|
||||
|
||||
from worlds.pokemon_rb.locations import location_data
|
||||
from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch
|
||||
|
||||
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}}
|
||||
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}}
|
||||
location_bytes_bits = {}
|
||||
for location in location_data:
|
||||
if location.ram_address is not None:
|
||||
@@ -39,6 +40,8 @@ CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
DISPLAY_MSGS = True
|
||||
|
||||
SCRIPT_VERSION = 3
|
||||
|
||||
|
||||
class GBCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
@@ -53,7 +56,6 @@ class GBCommandProcessor(ClientCommandProcessor):
|
||||
class GBContext(CommonContext):
|
||||
command_processor = GBCommandProcessor
|
||||
game = 'Pokemon Red and Blue'
|
||||
items_handling = 0b101
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
@@ -64,6 +66,12 @@ class GBContext(CommonContext):
|
||||
self.gb_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.display_msgs = True
|
||||
self.deathlink_pending = False
|
||||
self.set_deathlink = False
|
||||
self.client_compatibility_mode = 0
|
||||
self.items_handling = 0b001
|
||||
self.sent_release = False
|
||||
self.sent_collect = False
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -82,6 +90,8 @@ class GBContext(CommonContext):
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.locations_array = None
|
||||
if 'death_link' in args['slot_data'] and args['slot_data']['death_link']:
|
||||
self.set_deathlink = True
|
||||
elif cmd == "RoomInfo":
|
||||
self.seed_name = args['seed_name']
|
||||
elif cmd == 'Print':
|
||||
@@ -92,6 +102,10 @@ class GBContext(CommonContext):
|
||||
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
self.deathlink_pending = True
|
||||
super().on_deathlink(data)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
@@ -107,28 +121,29 @@ class GBContext(CommonContext):
|
||||
|
||||
def get_payload(ctx: GBContext):
|
||||
current_time = time.time()
|
||||
return json.dumps(
|
||||
ret = json.dumps(
|
||||
{
|
||||
"items": [item.item for item in ctx.items_received],
|
||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
||||
if key[0] > current_time - 10}
|
||||
if key[0] > current_time - 10},
|
||||
"deathlink": ctx.deathlink_pending,
|
||||
"options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled'))
|
||||
}
|
||||
)
|
||||
ctx.deathlink_pending = False
|
||||
return ret
|
||||
|
||||
|
||||
async def parse_locations(data: List, ctx: GBContext):
|
||||
locations = []
|
||||
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
|
||||
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], "Rod": data[0x140 + 0x20 + 0x0E:]}
|
||||
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E],
|
||||
"Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]}
|
||||
|
||||
# Check for clear problems
|
||||
if len(flags['Rod']) > 1:
|
||||
return
|
||||
if flags["EventFlag"][1] + flags["EventFlag"][8] + flags["EventFlag"][9] + flags["EventFlag"][12] \
|
||||
+ flags["EventFlag"][61] + flags["EventFlag"][62] + flags["EventFlag"][63] + flags["EventFlag"][64] \
|
||||
+ flags["EventFlag"][65] + flags["EventFlag"][66] + flags["EventFlag"][67] + flags["EventFlag"][68] \
|
||||
+ flags["EventFlag"][69] + flags["EventFlag"][70] != 0:
|
||||
return
|
||||
if len(data) > 0x140 + 0x20 + 0x0E + 0x01:
|
||||
flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:]
|
||||
else:
|
||||
flags["DexSanityFlag"] = [0] * 19
|
||||
|
||||
for flag_type, loc_map in location_map.items():
|
||||
for flag, loc_id in loc_map.items():
|
||||
@@ -168,8 +183,15 @@ async def gb_sync_task(ctx: GBContext):
|
||||
# 2. An array representing the memory values of the locations area (if in game)
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
||||
data_decoded = json.loads(data.decode())
|
||||
#print(data_decoded)
|
||||
|
||||
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
|
||||
msg = "You are connecting with an incompatible Lua script version. Ensure your connector Lua " \
|
||||
"and PokemonClient are from the same Archipelago installation."
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
ctx.client_compatibility_mode = data_decoded['clientCompatibilityVersion']
|
||||
if ctx.client_compatibility_mode == 0:
|
||||
ctx.items_handling = 0b101 # old patches will not have local start inventory, must be requested
|
||||
if ctx.seed_name and ctx.seed_name != ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]):
|
||||
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
@@ -179,13 +201,30 @@ async def gb_sync_task(ctx: GBContext):
|
||||
if not ctx.auth:
|
||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||
if ctx.auth == '':
|
||||
logger.info("Invalid ROM detected. No player name built into the ROM.")
|
||||
msg = "Invalid ROM detected. No player name built into the ROM."
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
if 'locations' in data_decoded and ctx.game and ctx.gb_status == CONNECTION_CONNECTED_STATUS \
|
||||
and not error_status and ctx.auth:
|
||||
# Not just a keep alive ping, parse
|
||||
async_start(parse_locations(data_decoded['locations'], ctx))
|
||||
if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags:
|
||||
await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!")
|
||||
if 'options' in data_decoded:
|
||||
msgs = []
|
||||
if data_decoded['options'] & 4 and not ctx.sent_release:
|
||||
ctx.sent_release = True
|
||||
msgs.append({"cmd": "Say", "text": "!release"})
|
||||
if data_decoded['options'] & 8 and not ctx.sent_collect:
|
||||
ctx.sent_collect = True
|
||||
msgs.append({"cmd": "Say", "text": "!collect"})
|
||||
if msgs:
|
||||
await ctx.send_msgs(msgs)
|
||||
if ctx.set_deathlink:
|
||||
await ctx.update_death_link(True)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
@@ -243,8 +282,16 @@ async def run_game(romfile):
|
||||
async def patch_and_run_game(game_version, patch_file, ctx):
|
||||
base_name = os.path.splitext(patch_file)[0]
|
||||
comp_path = base_name + '.gb'
|
||||
with open(Utils.local_path(Utils.get_options()["pokemon_rb_options"][f"{game_version}_rom_file"]), "rb") as stream:
|
||||
base_rom = bytes(stream.read())
|
||||
if game_version == "blue":
|
||||
delta_patch = BlueDeltaPatch
|
||||
else:
|
||||
delta_patch = RedDeltaPatch
|
||||
|
||||
try:
|
||||
base_rom = delta_patch.get_source_data()
|
||||
except Exception as msg:
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
|
||||
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
|
||||
with patch_archive.open('delta.bsdiff4', 'r') as stream:
|
||||
|
||||
@@ -33,6 +33,12 @@ Currently, the following games are supported:
|
||||
* Hylics 2
|
||||
* Overcooked! 2
|
||||
* Zillion
|
||||
* Lufia II Ancient Cave
|
||||
* Blasphemous
|
||||
* Wargroove
|
||||
* Stardew Valley
|
||||
* The Legend of Zelda
|
||||
* The Messenger
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
|
||||
30
SNIClient.py
30
SNIClient.py
@@ -56,7 +56,9 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
|
||||
"""Connect to a snes. Optionally include network address of a snes to connect to,
|
||||
otherwise show available devices; and a SNES device number if more than one SNES is detected.
|
||||
Examples: "/snes", "/snes 1", "/snes localhost:23074 1" """
|
||||
|
||||
if self.ctx.snes_state in {SNESState.SNES_ATTACHED, SNESState.SNES_CONNECTED, SNESState.SNES_CONNECTING}:
|
||||
self.output("Already connected to SNES. Disconnecting first.")
|
||||
self._cmd_snes_close()
|
||||
return self.connect_to_snes(snes_options)
|
||||
|
||||
def connect_to_snes(self, snes_options: str = "") -> bool:
|
||||
@@ -84,7 +86,7 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
|
||||
"""Close connection to a currently connected snes"""
|
||||
self.ctx.snes_reconnect_address = None
|
||||
self.ctx.cancel_snes_autoreconnect()
|
||||
if self.ctx.snes_socket is not None and not self.ctx.snes_socket.closed:
|
||||
if self.ctx.snes_socket and not self.ctx.snes_socket.closed:
|
||||
async_start(self.ctx.snes_socket.close())
|
||||
return True
|
||||
else:
|
||||
@@ -442,7 +444,8 @@ async def snes_connect(ctx: SNIContext, address: str, deviceIndex: int = -1) ->
|
||||
recv_task = asyncio.create_task(snes_recv_loop(ctx))
|
||||
|
||||
except Exception as e:
|
||||
if recv_task is not None:
|
||||
ctx.snes_state = SNESState.SNES_DISCONNECTED
|
||||
if task_alive(recv_task):
|
||||
if not ctx.snes_socket.closed:
|
||||
await ctx.snes_socket.close()
|
||||
else:
|
||||
@@ -450,15 +453,9 @@ async def snes_connect(ctx: SNIContext, address: str, deviceIndex: int = -1) ->
|
||||
if not ctx.snes_socket.closed:
|
||||
await ctx.snes_socket.close()
|
||||
ctx.snes_socket = None
|
||||
ctx.snes_state = SNESState.SNES_DISCONNECTED
|
||||
if not ctx.snes_reconnect_address:
|
||||
snes_logger.error("Error connecting to snes (%s)" % e)
|
||||
else:
|
||||
snes_logger.error(f"Error connecting to snes, retrying in {_global_snes_reconnect_delay} seconds")
|
||||
assert ctx.snes_autoreconnect_task is None
|
||||
ctx.snes_autoreconnect_task = asyncio.create_task(snes_autoreconnect(ctx), name="snes auto-reconnect")
|
||||
snes_logger.error(f"Error connecting to snes ({e}), retrying in {_global_snes_reconnect_delay} seconds")
|
||||
ctx.snes_autoreconnect_task = asyncio.create_task(snes_autoreconnect(ctx), name="snes auto-reconnect")
|
||||
_global_snes_reconnect_delay *= 2
|
||||
|
||||
else:
|
||||
_global_snes_reconnect_delay = ctx.starting_reconnect_delay
|
||||
snes_logger.info(f"Attached to {device}")
|
||||
@@ -471,10 +468,17 @@ async def snes_disconnect(ctx: SNIContext) -> None:
|
||||
ctx.snes_socket = None
|
||||
|
||||
|
||||
def task_alive(task: typing.Optional[asyncio.Task]) -> bool:
|
||||
if task:
|
||||
return not task.done()
|
||||
return False
|
||||
|
||||
|
||||
async def snes_autoreconnect(ctx: SNIContext) -> None:
|
||||
await asyncio.sleep(_global_snes_reconnect_delay)
|
||||
if ctx.snes_reconnect_address and not ctx.snes_socket and not ctx.snes_connect_task:
|
||||
ctx.snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_reconnect_address), name="SNES Connect")
|
||||
if not ctx.snes_socket and not task_alive(ctx.snes_connect_task):
|
||||
address = ctx.snes_reconnect_address if ctx.snes_reconnect_address else ctx.snes_address
|
||||
ctx.snes_connect_task = asyncio.create_task(snes_connect(ctx, address), name="SNES Connect")
|
||||
|
||||
|
||||
async def snes_recv_loop(ctx: SNIContext) -> None:
|
||||
|
||||
@@ -10,6 +10,8 @@ import re
|
||||
import sys
|
||||
import typing
|
||||
import queue
|
||||
import zipfile
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
@@ -50,9 +52,9 @@ class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
"""Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal"""
|
||||
options = difficulty.split()
|
||||
num_options = len(options)
|
||||
difficulty_choice = options[0].lower()
|
||||
|
||||
if num_options > 0:
|
||||
difficulty_choice = options[0].lower()
|
||||
if difficulty_choice == "casual":
|
||||
self.ctx.difficulty_override = 0
|
||||
elif difficulty_choice == "normal":
|
||||
@@ -69,7 +71,11 @@ class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
return True
|
||||
|
||||
else:
|
||||
self.output("Difficulty needs to be specified in the command.")
|
||||
if self.ctx.difficulty == -1:
|
||||
self.output("Please connect to a seed before checking difficulty.")
|
||||
else:
|
||||
self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][self.ctx.difficulty])
|
||||
self.output("To change the difficulty, add the name of the difficulty after the command.")
|
||||
return False
|
||||
|
||||
def _cmd_disable_mission_check(self) -> bool:
|
||||
@@ -120,9 +126,9 @@ class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
|
||||
return False
|
||||
|
||||
def _cmd_download_data(self, force: bool = False) -> bool:
|
||||
def _cmd_download_data(self) -> bool:
|
||||
"""Download the most recent release of the necessary files for playing SC2 with
|
||||
Archipelago. force should be True or False. force=True will overwrite your files."""
|
||||
Archipelago. Will overwrite existing files."""
|
||||
if "SC2PATH" not in os.environ:
|
||||
check_game_install_path()
|
||||
|
||||
@@ -132,11 +138,11 @@ class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
else:
|
||||
current_ver = None
|
||||
|
||||
tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData', current_version=current_ver, force_download=force)
|
||||
tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData',
|
||||
current_version=current_ver, force_download=True)
|
||||
|
||||
if tempzip != '':
|
||||
try:
|
||||
import zipfile
|
||||
zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"])
|
||||
sc2_logger.info(f"Download complete. Version {version} installed.")
|
||||
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "w") as f:
|
||||
@@ -195,12 +201,16 @@ class SC2Context(CommonContext):
|
||||
self.build_location_to_mission_mapping()
|
||||
|
||||
# Looks for the required maps and mods for SC2. Runs check_game_install_path.
|
||||
is_mod_installed_correctly()
|
||||
maps_present = is_mod_installed_correctly()
|
||||
if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"):
|
||||
with open(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt", "r") as f:
|
||||
current_ver = f.read()
|
||||
if is_mod_update_available("TheCondor07", "Starcraft2ArchipelagoData", current_ver):
|
||||
sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.")
|
||||
elif maps_present:
|
||||
sc2_logger.warning("NOTICE: Your map files may be outdated (version number not found). "
|
||||
"Run /download_data to update them.")
|
||||
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
# goes to this world
|
||||
@@ -639,6 +649,13 @@ def request_unfinished_missions(ctx: SC2Context):
|
||||
|
||||
_, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks)
|
||||
|
||||
# Removing All-In from location pool
|
||||
final_mission = lookup_id_to_mission[ctx.final_mission]
|
||||
if final_mission in unfinished_missions.keys():
|
||||
message = f"Final Mission Available: {final_mission}[{ctx.final_mission}]\n" + message
|
||||
if unfinished_missions[final_mission] == -1:
|
||||
unfinished_missions.pop(final_mission)
|
||||
|
||||
message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " +
|
||||
mark_up_objectives(
|
||||
f"[{len(unfinished_missions[mission])}/"
|
||||
@@ -996,7 +1013,7 @@ def download_latest_release_zip(owner: str, repo: str, current_version: str = No
|
||||
download_url = r1.json()["assets"][0]["browser_download_url"]
|
||||
|
||||
r2 = requests.get(download_url, headers=headers)
|
||||
if r2.status_code == 200:
|
||||
if r2.status_code == 200 and zipfile.is_zipfile(io.BytesIO(r2.content)):
|
||||
with open(f"{repo}.zip", "wb") as fh:
|
||||
fh.write(r2.content)
|
||||
sc2_logger.info(f"Successfully downloaded {repo}.zip.")
|
||||
|
||||
65
Utils.py
65
Utils.py
@@ -12,7 +12,7 @@ import io
|
||||
import collections
|
||||
import importlib
|
||||
import logging
|
||||
from typing import BinaryIO, ClassVar, Coroutine, Optional, Set
|
||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
|
||||
|
||||
from yaml import load, load_all, dump, SafeLoader
|
||||
|
||||
@@ -38,7 +38,7 @@ class Version(typing.NamedTuple):
|
||||
build: int
|
||||
|
||||
|
||||
__version__ = "0.3.6"
|
||||
__version__ = "0.3.9"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
@@ -99,7 +99,7 @@ def local_path(*path: str) -> str:
|
||||
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
||||
else:
|
||||
import __main__
|
||||
if hasattr(__main__, "__file__"):
|
||||
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
||||
# we are running in a normal Python environment
|
||||
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
||||
else:
|
||||
@@ -195,11 +195,11 @@ def get_public_ipv4() -> str:
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
ctx = get_cert_none_ssl_context()
|
||||
try:
|
||||
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx).read().decode("utf8").strip()
|
||||
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip()
|
||||
except Exception as e:
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip()
|
||||
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
|
||||
except Exception:
|
||||
logging.exception(e)
|
||||
pass # we could be offline, in a local game, so no point in erroring out
|
||||
@@ -213,7 +213,7 @@ def get_public_ipv6() -> str:
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
ctx = get_cert_none_ssl_context()
|
||||
try:
|
||||
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx).read().decode("utf8").strip()
|
||||
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
pass # we could be offline, in a local game, or ipv6 may not be available
|
||||
@@ -236,7 +236,7 @@ def get_default_options() -> OptionsType:
|
||||
"bridge_chat_out": True,
|
||||
},
|
||||
"sni_options": {
|
||||
"sni": "SNI",
|
||||
"sni_path": "SNI",
|
||||
"snes_rom_start": True,
|
||||
},
|
||||
"sm_options": {
|
||||
@@ -260,7 +260,7 @@ def get_default_options() -> OptionsType:
|
||||
"disable_item_cheat": False,
|
||||
"location_check_points": 1,
|
||||
"hint_cost": 10,
|
||||
"forfeit_mode": "goal",
|
||||
"release_mode": "goal",
|
||||
"collect_mode": "disabled",
|
||||
"remaining_mode": "goal",
|
||||
"auto_shutdown": 0,
|
||||
@@ -268,13 +268,12 @@ def get_default_options() -> OptionsType:
|
||||
"log_network": 0
|
||||
},
|
||||
"generator": {
|
||||
"teams": 1,
|
||||
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
|
||||
"player_files_path": "Players",
|
||||
"players": 0,
|
||||
"weights_file_path": "weights.yaml",
|
||||
"meta_file_path": "meta.yaml",
|
||||
"spoiler": 2,
|
||||
"spoiler": 3,
|
||||
"glitch_triforce_room": 1,
|
||||
"race": 0,
|
||||
"plando_options": "bosses",
|
||||
@@ -286,6 +285,7 @@ def get_default_options() -> OptionsType:
|
||||
},
|
||||
"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",
|
||||
@@ -303,9 +303,22 @@ def get_default_options() -> OptionsType:
|
||||
"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
|
||||
|
||||
|
||||
@@ -452,6 +465,7 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
|
||||
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
|
||||
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
||||
exception_logger: typing.Optional[str] = None):
|
||||
import datetime
|
||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||
log_folder = user_path("logs")
|
||||
os.makedirs(log_folder, exist_ok=True)
|
||||
@@ -460,6 +474,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
root_logger.removeHandler(handler)
|
||||
handler.close()
|
||||
root_logger.setLevel(loglevel)
|
||||
if "a" not in write_mode:
|
||||
name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}"
|
||||
file_handler = logging.FileHandler(
|
||||
os.path.join(log_folder, f"{name}.txt"),
|
||||
write_mode,
|
||||
@@ -487,7 +503,25 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
|
||||
sys.excepthook = handle_exception
|
||||
|
||||
logging.info(f"Archipelago ({__version__}) logging initialized.")
|
||||
def _cleanup():
|
||||
for file in os.scandir(log_folder):
|
||||
if file.name.endswith(".txt"):
|
||||
last_change = datetime.datetime.fromtimestamp(file.stat().st_mtime)
|
||||
if datetime.datetime.now() - last_change > datetime.timedelta(days=7):
|
||||
try:
|
||||
os.unlink(file.path)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
else:
|
||||
logging.debug(f"Deleted old logfile {file.path}")
|
||||
import threading
|
||||
threading.Thread(target=_cleanup, name="LogCleaner").start()
|
||||
import platform
|
||||
logging.info(
|
||||
f"Archipelago ({__version__}) logging initialized"
|
||||
f" on {platform.platform()}"
|
||||
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||
)
|
||||
|
||||
|
||||
def stream_input(stream, queue):
|
||||
@@ -636,7 +670,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
|
||||
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
|
||||
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
|
||||
def sorter(element: str) -> str:
|
||||
def sorter(element: Union[str, Dict[str, Any]]) -> str:
|
||||
if (not isinstance(element, str)):
|
||||
element = element["title"]
|
||||
|
||||
parts = element.split(maxsplit=1)
|
||||
if parts[0].lower() in ignore:
|
||||
return parts[1].lower()
|
||||
@@ -656,7 +693,7 @@ def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
|
||||
_faf_tasks: "Set[asyncio.Task[None]]" = set()
|
||||
|
||||
|
||||
def async_start(co: Coroutine[None, None, None], name: Optional[str] = None) -> None:
|
||||
def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None:
|
||||
"""
|
||||
Use this to start a task when you don't keep a reference to it or immediately await it,
|
||||
to prevent early garbage collection. "fire-and-forget"
|
||||
|
||||
445
WargrooveClient.py
Normal file
445
WargrooveClient.py
Normal file
@@ -0,0 +1,445 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import random
|
||||
import shutil
|
||||
from typing import Tuple, List, Iterable, Dict
|
||||
|
||||
from worlds.wargroove import WargrooveWorld
|
||||
from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
import json
|
||||
import logging
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("WargrooveClient", exception_logger="Client")
|
||||
|
||||
from NetUtils import NetworkItem, ClientStatus
|
||||
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
||||
CommonContext, server_loop
|
||||
|
||||
wg_logger = logging.getLogger("WG")
|
||||
|
||||
|
||||
class WargrooveClientCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.output(f"Syncing items.")
|
||||
self.ctx.syncing = True
|
||||
|
||||
def _cmd_commander(self, *commander_name: Iterable[str]):
|
||||
"""Set the current commander to the given commander."""
|
||||
if commander_name:
|
||||
self.ctx.set_commander(' '.join(commander_name))
|
||||
else:
|
||||
if self.ctx.can_choose_commander:
|
||||
commanders = self.ctx.get_commanders()
|
||||
wg_logger.info('Unlocked commanders: ' +
|
||||
', '.join((commander.name for commander, unlocked in commanders if unlocked)))
|
||||
wg_logger.info('Locked commanders: ' +
|
||||
', '.join((commander.name for commander, unlocked in commanders if not unlocked)))
|
||||
else:
|
||||
wg_logger.error('Cannot set commanders in this game mode.')
|
||||
|
||||
|
||||
class WargrooveContext(CommonContext):
|
||||
command_processor: int = WargrooveClientCommandProcessor
|
||||
game = "Wargroove"
|
||||
items_handling = 0b111 # full remote
|
||||
current_commander: CommanderData = faction_table["Starter"][0]
|
||||
can_choose_commander: bool = False
|
||||
commander_defense_boost_multiplier: int = 0
|
||||
income_boost_multiplier: int = 0
|
||||
starting_groove_multiplier: float
|
||||
faction_item_ids = {
|
||||
'Starter': 0,
|
||||
'Cherrystone': 52025,
|
||||
'Felheim': 52026,
|
||||
'Floran': 52027,
|
||||
'Heavensong': 52028,
|
||||
'Requiem': 52029,
|
||||
'Outlaw': 52030
|
||||
}
|
||||
buff_item_ids = {
|
||||
'Income Boost': 52023,
|
||||
'Commander Defense Boost': 52024,
|
||||
}
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(WargrooveContext, self).__init__(server_address, password)
|
||||
self.send_index: int = 0
|
||||
self.syncing = False
|
||||
self.awaiting_bridge = False
|
||||
# self.game_communication_path: files go in this path to pass data between us and the actual game
|
||||
if "appdata" in os.environ:
|
||||
options = Utils.get_options()
|
||||
root_directory = os.path.join(options["wargroove_options"]["root_directory"])
|
||||
data_directory = os.path.join("lib", "worlds", "wargroove", "data")
|
||||
dev_data_directory = os.path.join("worlds", "wargroove", "data")
|
||||
appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
|
||||
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
|
||||
print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
|
||||
"Unable to infer required game_communication_path")
|
||||
self.game_communication_path = os.path.join(root_directory, "AP")
|
||||
if not os.path.exists(self.game_communication_path):
|
||||
os.makedirs(self.game_communication_path)
|
||||
self.remove_communication_files()
|
||||
atexit.register(self.remove_communication_files)
|
||||
if not os.path.isdir(appdata_wargroove):
|
||||
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
|
||||
"Boot Wargroove and then close it to attempt to fix this error")
|
||||
if not os.path.isdir(data_directory):
|
||||
data_directory = dev_data_directory
|
||||
if not os.path.isdir(data_directory):
|
||||
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
|
||||
shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
|
||||
else:
|
||||
print_error_and_close("WargrooveClient couldn't detect system type. "
|
||||
"Unable to infer required game_communication_path")
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(WargrooveContext, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
async def connection_closed(self):
|
||||
await super(WargrooveContext, self).connection_closed()
|
||||
self.remove_communication_files()
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
if self.server:
|
||||
return [self.server]
|
||||
else:
|
||||
return []
|
||||
|
||||
async def shutdown(self):
|
||||
await super(WargrooveContext, self).shutdown()
|
||||
self.remove_communication_files()
|
||||
|
||||
def remove_communication_files(self):
|
||||
for root, dirs, files in os.walk(self.game_communication_path):
|
||||
for file in files:
|
||||
os.remove(root + "/" + file)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
filename = f"AP_settings.json"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
slot_data = args["slot_data"]
|
||||
json.dump(args["slot_data"], f)
|
||||
self.can_choose_commander = slot_data["can_choose_commander"]
|
||||
print('can choose commander:', self.can_choose_commander)
|
||||
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
|
||||
self.income_boost_multiplier = slot_data["income_boost"]
|
||||
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
|
||||
f.close()
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.close()
|
||||
self.update_commander_data()
|
||||
self.ui.update_tracker()
|
||||
|
||||
random.seed(self.seed_name + str(self.slot))
|
||||
# Our indexes start at 1 and we have 24 levels
|
||||
for i in range(1, 25):
|
||||
filename = f"seed{i}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.write(str(random.randint(0, 4294967295)))
|
||||
f.close()
|
||||
|
||||
if cmd in {"RoomInfo"}:
|
||||
self.seed_name = args["seed_name"]
|
||||
|
||||
if cmd in {"ReceivedItems"}:
|
||||
received_ids = [item.item for item in self.items_received]
|
||||
for network_item in self.items_received:
|
||||
filename = f"AP_{str(network_item.item)}.item"
|
||||
path = os.path.join(self.game_communication_path, filename)
|
||||
|
||||
# Newly-obtained items
|
||||
if not os.path.isfile(path):
|
||||
open(path, 'w').close()
|
||||
# Announcing commander unlocks
|
||||
item_name = self.item_names[network_item.item]
|
||||
if item_name in faction_table.keys():
|
||||
for commander in faction_table[item_name]:
|
||||
logger.info(f"{commander.name} has been unlocked!")
|
||||
|
||||
with open(path, 'w') as f:
|
||||
item_count = received_ids.count(network_item.item)
|
||||
if self.buff_item_ids["Income Boost"] == network_item.item:
|
||||
f.write(f"{item_count * self.income_boost_multiplier}")
|
||||
elif self.buff_item_ids["Commander Defense Boost"] == network_item.item:
|
||||
f.write(f"{item_count * self.commander_defense_boost_multiplier}")
|
||||
else:
|
||||
f.write(f"{item_count}")
|
||||
f.close()
|
||||
|
||||
print_filename = f"AP_{str(network_item.item)}.item.print"
|
||||
print_path = os.path.join(self.game_communication_path, print_filename)
|
||||
if not os.path.isfile(print_path):
|
||||
open(print_path, 'w').close()
|
||||
with open(print_path, 'w') as f:
|
||||
f.write("Received " +
|
||||
self.item_names[network_item.item] +
|
||||
" from " +
|
||||
self.player_names[network_item.player])
|
||||
f.close()
|
||||
self.update_commander_data()
|
||||
self.ui.update_tracker()
|
||||
|
||||
if cmd in {"RoomUpdate"}:
|
||||
if "checked_locations" in args:
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
f.close()
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager, HoverBehavior, ServerToolTip
|
||||
from kivy.uix.tabbedpanel import TabbedPanelItem
|
||||
from kivy.lang import Builder
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.togglebutton import ToggleButton
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.image import AsyncImage, Image
|
||||
from kivy.uix.stacklayout import StackLayout
|
||||
from kivy.uix.label import Label
|
||||
from kivy.properties import ColorProperty
|
||||
from kivy.uix.image import Image
|
||||
import pkgutil
|
||||
|
||||
class TrackerLayout(BoxLayout):
|
||||
pass
|
||||
|
||||
class CommanderSelect(BoxLayout):
|
||||
pass
|
||||
|
||||
class CommanderButton(ToggleButton):
|
||||
pass
|
||||
|
||||
class FactionBox(BoxLayout):
|
||||
pass
|
||||
|
||||
class CommanderGroup(BoxLayout):
|
||||
pass
|
||||
|
||||
class ItemTracker(BoxLayout):
|
||||
pass
|
||||
|
||||
class ItemLabel(Label):
|
||||
pass
|
||||
|
||||
class WargrooveManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("WG", "WG Console"),
|
||||
]
|
||||
base_title = "Archipelago Wargroove Client"
|
||||
ctx: WargrooveContext
|
||||
unit_tracker: ItemTracker
|
||||
trigger_tracker: BoxLayout
|
||||
boost_tracker: BoxLayout
|
||||
commander_buttons: Dict[int, List[CommanderButton]]
|
||||
tracker_items = {
|
||||
"Swordsman": ItemData(None, "Unit", False),
|
||||
"Dog": ItemData(None, "Unit", False),
|
||||
**item_table
|
||||
}
|
||||
|
||||
def build(self):
|
||||
container = super().build()
|
||||
panel = TabbedPanelItem(text="Wargroove")
|
||||
panel.content = self.build_tracker()
|
||||
self.tabs.add_widget(panel)
|
||||
return container
|
||||
|
||||
def build_tracker(self) -> TrackerLayout:
|
||||
try:
|
||||
tracker = TrackerLayout(orientation="horizontal")
|
||||
commander_select = CommanderSelect(orientation="vertical")
|
||||
self.commander_buttons = {}
|
||||
|
||||
for faction, commanders in faction_table.items():
|
||||
faction_box = FactionBox(size_hint=(None, None), width=100 * len(commanders), height=70)
|
||||
commander_group = CommanderGroup()
|
||||
commander_buttons = []
|
||||
for commander in commanders:
|
||||
commander_button = CommanderButton(text=commander.name, group="commanders")
|
||||
if faction == "Starter":
|
||||
commander_button.disabled = False
|
||||
commander_button.bind(on_press=lambda instance: self.ctx.set_commander(instance.text))
|
||||
commander_buttons.append(commander_button)
|
||||
commander_group.add_widget(commander_button)
|
||||
self.commander_buttons[faction] = commander_buttons
|
||||
faction_box.add_widget(Label(text=faction, size_hint_x=None, pos_hint={'left': 1}, size_hint_y=None, height=10))
|
||||
faction_box.add_widget(commander_group)
|
||||
commander_select.add_widget(faction_box)
|
||||
item_tracker = ItemTracker(padding=[0,20])
|
||||
self.unit_tracker = BoxLayout(orientation="vertical")
|
||||
other_tracker = BoxLayout(orientation="vertical")
|
||||
self.trigger_tracker = BoxLayout(orientation="vertical")
|
||||
self.boost_tracker = BoxLayout(orientation="vertical")
|
||||
other_tracker.add_widget(self.trigger_tracker)
|
||||
other_tracker.add_widget(self.boost_tracker)
|
||||
item_tracker.add_widget(self.unit_tracker)
|
||||
item_tracker.add_widget(other_tracker)
|
||||
tracker.add_widget(commander_select)
|
||||
tracker.add_widget(item_tracker)
|
||||
self.update_tracker()
|
||||
return tracker
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
def update_tracker(self):
|
||||
received_ids = [item.item for item in self.ctx.items_received]
|
||||
for faction, item_id in self.ctx.faction_item_ids.items():
|
||||
for commander_button in self.commander_buttons[faction]:
|
||||
commander_button.disabled = not (faction == "Starter" or item_id in received_ids)
|
||||
self.unit_tracker.clear_widgets()
|
||||
self.trigger_tracker.clear_widgets()
|
||||
for name, item in self.tracker_items.items():
|
||||
if item.type in ("Unit", "Trigger"):
|
||||
status_color = (1, 1, 1, 1) if item.code is None or item.code in received_ids else (0.6, 0.2, 0.2, 1)
|
||||
label = ItemLabel(text=name, color=status_color)
|
||||
if item.type == "Unit":
|
||||
self.unit_tracker.add_widget(label)
|
||||
else:
|
||||
self.trigger_tracker.add_widget(label)
|
||||
self.boost_tracker.clear_widgets()
|
||||
extra_income = received_ids.count(52023) * self.ctx.income_boost_multiplier
|
||||
extra_defense = received_ids.count(52024) * self.ctx.commander_defense_boost_multiplier
|
||||
income_boost = ItemLabel(text="Extra Income: " + str(extra_income))
|
||||
defense_boost = ItemLabel(text="Comm Defense: " + str(100 + extra_defense))
|
||||
self.boost_tracker.add_widget(income_boost)
|
||||
self.boost_tracker.add_widget(defense_boost)
|
||||
|
||||
self.ui = WargrooveManager(self)
|
||||
data = pkgutil.get_data(WargrooveWorld.__module__, "Wargroove.kv").decode()
|
||||
Builder.load_string(data)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def update_commander_data(self):
|
||||
if self.can_choose_commander:
|
||||
faction_items = 0
|
||||
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
|
||||
for network_item in self.items_received:
|
||||
if self.item_names[network_item.item] in faction_item_names:
|
||||
faction_items += 1
|
||||
starting_groove = (faction_items - 1) * self.starting_groove_multiplier
|
||||
# Must be an integer larger than 0
|
||||
starting_groove = int(max(starting_groove, 0))
|
||||
data = {
|
||||
"commander": self.current_commander.internal_name,
|
||||
"starting_groove": starting_groove
|
||||
}
|
||||
else:
|
||||
data = {
|
||||
"commander": "seed",
|
||||
"starting_groove": 0
|
||||
}
|
||||
filename = 'commander.json'
|
||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
||||
json.dump(data, f)
|
||||
if self.ui:
|
||||
self.ui.update_tracker()
|
||||
|
||||
def set_commander(self, commander_name: str) -> bool:
|
||||
"""Sets the current commander to the given one, if possible"""
|
||||
if not self.can_choose_commander:
|
||||
wg_logger.error("Cannot set commanders in this game mode.")
|
||||
return
|
||||
match_name = commander_name.lower()
|
||||
for commander, unlocked in self.get_commanders():
|
||||
if commander.name.lower() == match_name or commander.alt_name and commander.alt_name.lower() == match_name:
|
||||
if unlocked:
|
||||
self.current_commander = commander
|
||||
self.syncing = True
|
||||
wg_logger.info(f"Commander set to {commander.name}.")
|
||||
self.update_commander_data()
|
||||
return True
|
||||
else:
|
||||
wg_logger.error(f"Commander {commander.name} has not been unlocked.")
|
||||
return False
|
||||
else:
|
||||
wg_logger.error(f"{commander_name} is not a recognized Wargroove commander.")
|
||||
|
||||
def get_commanders(self) -> List[Tuple[CommanderData, bool]]:
|
||||
"""Gets a list of commanders with their unlocked status"""
|
||||
commanders = []
|
||||
received_ids = [item.item for item in self.items_received]
|
||||
for faction in faction_table.keys():
|
||||
unlocked = faction == 'Starter' or self.faction_item_ids[faction] in received_ids
|
||||
commanders += [(commander, unlocked) for commander in faction_table[faction]]
|
||||
return commanders
|
||||
|
||||
|
||||
async def game_watcher(ctx: WargrooveContext):
|
||||
from worlds.wargroove.Locations import location_table
|
||||
while not ctx.exit_event.is_set():
|
||||
if ctx.syncing == True:
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
ctx.syncing = False
|
||||
sending = []
|
||||
victory = False
|
||||
for root, dirs, files in os.walk(ctx.game_communication_path):
|
||||
for file in files:
|
||||
if file.find("send") > -1:
|
||||
st = file.split("send", -1)[1]
|
||||
sending = sending+[(int(st))]
|
||||
if file.find("victory") > -1:
|
||||
victory = True
|
||||
ctx.locations_checked = sending
|
||||
message = [{"cmd": 'LocationChecks', "locations": sending}]
|
||||
await ctx.send_msgs(message)
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
def print_error_and_close(msg):
|
||||
logger.error("Error: " + msg)
|
||||
Utils.messagebox("Error", msg, error=True)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
async def main(args):
|
||||
ctx = WargrooveContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
progression_watcher = asyncio.create_task(
|
||||
game_watcher(ctx), name="WargrooveProgressionWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await progression_watcher
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
@@ -29,10 +29,15 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
def get_app():
|
||||
register()
|
||||
app = raw_app
|
||||
if os.path.exists(configpath):
|
||||
if os.path.exists(configpath) and not app.config["TESTING"]:
|
||||
import yaml
|
||||
app.config.from_file(configpath, yaml.safe_load)
|
||||
logging.info(f"Updated config from {configpath}")
|
||||
if not app.config["HOST_ADDRESS"]:
|
||||
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
|
||||
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
|
||||
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
|
||||
|
||||
db.bind(**app.config["PONY"])
|
||||
db.generate_mapping(create_tables=True)
|
||||
return app
|
||||
|
||||
@@ -24,6 +24,8 @@ app.jinja_env.filters['all'] = all
|
||||
app.config["SELFHOST"] = True # application process is in charge of running the websites
|
||||
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
||||
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
|
||||
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
|
||||
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
||||
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
||||
app.config["DEBUG"] = False
|
||||
app.config["PORT"] = 80
|
||||
@@ -49,7 +51,7 @@ app.config["PONY"] = {
|
||||
app.config["MAX_ROLL"] = 20
|
||||
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
|
||||
app.config["JSON_AS_ASCII"] = False
|
||||
app.config["PATCH_TARGET"] = "archipelago.gg"
|
||||
app.config["HOST_ADDRESS"] = ""
|
||||
|
||||
cache = Cache(app)
|
||||
Compress(app)
|
||||
|
||||
@@ -39,10 +39,11 @@ def get_datapackage():
|
||||
|
||||
@api_endpoints.route('/datapackage_version')
|
||||
@cache.cached()
|
||||
|
||||
def get_datapackage_versions():
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
|
||||
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||
version_package["version"] = network_data_package["version"]
|
||||
return version_package
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import pickle
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request, session, url_for
|
||||
from flask import request, session, url_for, Markup
|
||||
from pony.orm import commit
|
||||
|
||||
from WebHostLib import app
|
||||
@@ -21,13 +21,18 @@ def generate_api():
|
||||
if 'file' in request.files:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
if isinstance(options, Markup):
|
||||
return {"text": options.striptags()}, 400
|
||||
if isinstance(options, str):
|
||||
return {"text": options}, 400
|
||||
if "race" in request.form:
|
||||
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
|
||||
meta_options_source = request.form
|
||||
|
||||
json_data = request.get_json()
|
||||
# json_data is optional, we can have it silently fall to None as it used to do.
|
||||
# See https://flask.palletsprojects.com/en/2.2.x/api/#flask.Request.get_json -> Changelog -> 2.1
|
||||
json_data = request.get_json(silent=True)
|
||||
|
||||
if json_data:
|
||||
meta_options_source = json_data
|
||||
if 'weights' in json_data:
|
||||
|
||||
@@ -177,6 +177,9 @@ class MultiworldInstance():
|
||||
with guardian_lock:
|
||||
multiworlds[self.room_id] = self
|
||||
self.ponyconfig = config["PONY"]
|
||||
self.cert = config["SELFLAUNCHCERT"]
|
||||
self.key = config["SELFLAUNCHKEY"]
|
||||
self.host = config["HOST_ADDRESS"]
|
||||
|
||||
def start(self):
|
||||
if self.process and self.process.is_alive():
|
||||
@@ -184,7 +187,8 @@ class MultiworldInstance():
|
||||
|
||||
logging.info(f"Spinning up {self.room_id}")
|
||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.room_id, self.ponyconfig, get_static_server_data()),
|
||||
args=(self.room_id, self.ponyconfig, get_static_server_data(),
|
||||
self.cert, self.key, self.host),
|
||||
name="MultiHost")
|
||||
process.start()
|
||||
# bind after start to prevent thread sync issues with guardian.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import zipfile
|
||||
from typing import *
|
||||
|
||||
from flask import request, flash, redirect, url_for, render_template
|
||||
from flask import request, flash, redirect, url_for, render_template, Markup
|
||||
|
||||
from WebHostLib import app
|
||||
|
||||
@@ -12,7 +12,7 @@ def allowed_file(filename):
|
||||
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||
|
||||
|
||||
from Generate import roll_settings, PlandoSettings
|
||||
from Generate import roll_settings, PlandoOptions
|
||||
from Utils import parse_yamls
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ def check():
|
||||
else:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
if isinstance(options, str):
|
||||
flash(options)
|
||||
else:
|
||||
results, _ = roll_options(options)
|
||||
@@ -38,7 +38,7 @@ def mysterycheck():
|
||||
return redirect(url_for("check"), 301)
|
||||
|
||||
|
||||
def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
|
||||
options = {}
|
||||
# if user does not select file, browser also
|
||||
# submit an empty part without filename
|
||||
@@ -50,6 +50,10 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
with zipfile.ZipFile(file, 'r') as zfile:
|
||||
infolist = zfile.infolist()
|
||||
|
||||
if any(file.filename.endswith(".archipelago") for file in infolist):
|
||||
return Markup("Error: Your .zip file contains an .archipelago file. "
|
||||
'Did you mean to <a href="/uploads">host a game</a>?')
|
||||
|
||||
for file in infolist:
|
||||
if file.filename.endswith(banned_zip_contents):
|
||||
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted."
|
||||
@@ -65,7 +69,7 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
def roll_options(options: Dict[str, Union[dict, str]],
|
||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||
plando_options = PlandoSettings.from_set(set(plando_options))
|
||||
plando_options = PlandoOptions.from_set(set(plando_options))
|
||||
results = {}
|
||||
rolled_results = {}
|
||||
for filename, text in options.items():
|
||||
|
||||
@@ -10,12 +10,14 @@ import random
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
|
||||
import websockets
|
||||
from pony.orm import db_session, commit, select
|
||||
from pony.orm import commit, db_session, select
|
||||
|
||||
import Utils
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||
|
||||
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
|
||||
|
||||
@@ -66,7 +68,6 @@ class WebHostContext(Context):
|
||||
def _load_game_data(self):
|
||||
for key, value in self.static_server_data.items():
|
||||
setattr(self, key, value)
|
||||
self.forced_auto_forfeits = collections.defaultdict(lambda: False, self.forced_auto_forfeits)
|
||||
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||
|
||||
def listen_to_db_commands(self):
|
||||
@@ -126,21 +127,23 @@ def get_random_port():
|
||||
def get_static_server_data() -> dict:
|
||||
import worlds
|
||||
data = {
|
||||
"forced_auto_forfeits": {},
|
||||
"non_hintable_names": {},
|
||||
"gamespackage": worlds.network_data_package["games"],
|
||||
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()},
|
||||
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()},
|
||||
}
|
||||
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
data["forced_auto_forfeits"][world_name] = world.forced_auto_forfeit
|
||||
data["non_hintable_names"][world_name] = world.hint_blacklist
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
|
||||
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
|
||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||
host: str):
|
||||
# establish DB connection for multidata and multisave
|
||||
db.bind(**ponyconfig)
|
||||
db.generate_mapping(check_tables=False)
|
||||
@@ -150,32 +153,33 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
|
||||
ctx = WebHostContext(static_server_data)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
|
||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
||||
try:
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
|
||||
ping_interval=None)
|
||||
ping_interval=None, ssl=ssl_context)
|
||||
|
||||
await ctx.server
|
||||
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None,
|
||||
ping_interval=None)
|
||||
ping_interval=None, ssl=ssl_context)
|
||||
|
||||
await ctx.server
|
||||
port = 0
|
||||
for wssocket in ctx.server.ws_server.sockets:
|
||||
socketname = wssocket.getsockname()
|
||||
if wssocket.family == socket.AF_INET6:
|
||||
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
|
||||
# Prefer IPv4, as most users seem to not have working ipv6 support
|
||||
if not port:
|
||||
port = socketname[1]
|
||||
elif wssocket.family == socket.AF_INET:
|
||||
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
|
||||
port = socketname[1]
|
||||
if port:
|
||||
logging.info(f'Hosting game at {host}:{port}')
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = port
|
||||
else:
|
||||
logging.exception("Could not determine port. Likely hosting failure.")
|
||||
with db_session:
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
|
||||
@@ -26,7 +26,7 @@ def download_patch(room_id, patch_id):
|
||||
with zipfile.ZipFile(filelike, "a") as zf:
|
||||
with zf.open("archipelago.json", "r") as f:
|
||||
manifest = json.load(f)
|
||||
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None
|
||||
manifest["server"] = f"{app.config['HOST_ADDRESS']}:{last_port}" if last_port else None
|
||||
with zipfile.ZipFile(new_file, "w") as new_zip:
|
||||
for file in zf.infolist():
|
||||
if file.filename == "archipelago.json":
|
||||
@@ -64,7 +64,7 @@ def download_slot_file(room_id, player_id: int):
|
||||
if slot_data.game == "Minecraft":
|
||||
from worlds.minecraft import mc_update_output
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
||||
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
|
||||
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
|
||||
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
|
||||
elif slot_data.game == "Factorio":
|
||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||
@@ -72,7 +72,14 @@ def download_slot_file(room_id, player_id: int):
|
||||
if name.endswith("info.json"):
|
||||
fname = name.rsplit("/", 1)[0] + ".zip"
|
||||
elif slot_data.game == "Ocarina of Time":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||
stream = io.BytesIO(slot_data.data)
|
||||
if zipfile.is_zipfile(stream):
|
||||
with zipfile.ZipFile(stream) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith(".zpf"):
|
||||
fname = name.rsplit(".", 1)[0] + ".apz5"
|
||||
else: # pre-ootr-7.0 support
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||
elif slot_data.game == "VVVVVV":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
||||
elif slot_data.game == "Zillion":
|
||||
|
||||
@@ -12,7 +12,7 @@ from flask import request, flash, redirect, url_for, session, render_template
|
||||
from pony.orm import commit, db_session
|
||||
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
from Generate import handle_name, PlandoSettings
|
||||
from Generate import handle_name, PlandoOptions
|
||||
from Main import main as ERmain
|
||||
from Utils import __version__
|
||||
from WebHostLib import app
|
||||
@@ -33,7 +33,7 @@ def get_meta(options_source: dict) -> dict:
|
||||
|
||||
server_options = {
|
||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
|
||||
"release_mode": options_source.get("release_mode", "goal"),
|
||||
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||
@@ -52,7 +52,7 @@ def generate(race=False):
|
||||
else:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
if isinstance(options, str):
|
||||
flash(options)
|
||||
else:
|
||||
meta = get_meta(request.form)
|
||||
@@ -92,7 +92,7 @@ def generate(race=False):
|
||||
return render_template("generate.html", race=race, version=__version__)
|
||||
|
||||
|
||||
def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||
if not meta:
|
||||
meta: Dict[str, Any] = {}
|
||||
|
||||
@@ -114,12 +114,12 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
|
||||
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 2
|
||||
erargs.spoiler = 0 if race else 3
|
||||
erargs.race = race
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options",
|
||||
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
|
||||
name_counter = Counter()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import datetime
|
||||
import os
|
||||
from typing import List, Dict, Union
|
||||
|
||||
import jinja2.exceptions
|
||||
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||
@@ -69,10 +70,6 @@ def tutorial(game, file, lang):
|
||||
|
||||
@app.route('/tutorial/')
|
||||
def tutorial_landing():
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world
|
||||
return render_template("tutorialLanding.html")
|
||||
|
||||
|
||||
@@ -167,8 +164,9 @@ def get_datapackage():
|
||||
@app.route('/index')
|
||||
@app.route('/sitemap')
|
||||
def get_sitemap():
|
||||
available_games = []
|
||||
available_games: List[Dict[str, Union[str, bool]]] = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
available_games.append(game)
|
||||
has_settings: bool = isinstance(world.web.settings_page, bool) and world.web.settings_page
|
||||
available_games.append({ 'title': game, 'has_settings': has_settings })
|
||||
return render_template("siteMap.html", games=available_games)
|
||||
|
||||
@@ -71,7 +71,7 @@ def create():
|
||||
|
||||
del file_data
|
||||
|
||||
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
flask>=2.2.2
|
||||
flask>=2.2.3
|
||||
pony>=0.7.16
|
||||
waitress>=2.1.2
|
||||
Flask-Caching>=2.0.1
|
||||
Flask-Caching>=2.0.2
|
||||
Flask-Compress>=1.13
|
||||
Flask-Limiter>=2.7.0
|
||||
bokeh>=3.0.0
|
||||
Flask-Limiter>=3.3.0
|
||||
bokeh>=3.1.0
|
||||
|
||||
@@ -4,6 +4,7 @@ window.addEventListener('load', () => {
|
||||
"ordering": true,
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
console.log(tables);
|
||||
});
|
||||
|
||||
18
WebHostLib/static/assets/baseHeader.js
Normal file
18
WebHostLib/static/assets/baseHeader.js
Normal file
@@ -0,0 +1,18 @@
|
||||
window.addEventListener('load', () => {
|
||||
const menuButton = document.getElementById('base-header-mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('base-header-mobile-menu');
|
||||
|
||||
menuButton.addEventListener('click', (evt) => {
|
||||
evt.preventDefault();
|
||||
|
||||
if (!mobileMenu.style.display || mobileMenu.style.display === 'none') {
|
||||
return mobileMenu.style.display = 'flex';
|
||||
}
|
||||
|
||||
mobileMenu.style.display = 'none';
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
mobileMenu.style.display = 'none';
|
||||
});
|
||||
});
|
||||
49
WebHostLib/static/assets/checksfinderTracker.js
Normal file
49
WebHostLib/static/assets/checksfinderTracker.js
Normal file
@@ -0,0 +1,49 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Reload tracker every 60 seconds
|
||||
const url = window.location;
|
||||
setInterval(() => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
|
||||
// Create a fake DOM using the returned HTML
|
||||
const domParser = new DOMParser();
|
||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||
|
||||
// Update item tracker
|
||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
||||
// Update only counters in the location-table
|
||||
let counters = document.getElementsByClassName('counter');
|
||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
||||
for (let i = 0; i < counters.length; i++) {
|
||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
||||
}
|
||||
};
|
||||
ajax.open('GET', url);
|
||||
ajax.send();
|
||||
}, 60000)
|
||||
|
||||
// Collapsible advancement sections
|
||||
const categories = document.getElementsByClassName("location-category");
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
let hide_id = categories[i].id.split('-')[0];
|
||||
if (hide_id == 'Total') {
|
||||
continue;
|
||||
}
|
||||
categories[i].addEventListener('click', function() {
|
||||
// Toggle the advancement list
|
||||
document.getElementById(hide_id).classList.toggle("hide");
|
||||
// Change text of the header
|
||||
const tab_header = document.getElementById(hide_id+'-header').children[0];
|
||||
const orig_text = tab_header.innerHTML;
|
||||
let new_text;
|
||||
if (orig_text.includes("▼")) {
|
||||
new_text = orig_text.replace("▼", "▲");
|
||||
}
|
||||
else {
|
||||
new_text = orig_text.replace("▲", "▼");
|
||||
}
|
||||
tab_header.innerHTML = new_text;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -20,7 +20,7 @@ comfortable exploiting certain glitches in the game.
|
||||
## What is a multi-world?
|
||||
|
||||
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a
|
||||
two player multi-world, players A and B each get their own randomized version of a game, called seeds. In each player's
|
||||
two player multi-world, players A and B each get their own randomized version of a game, called a world. In each player's
|
||||
game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the
|
||||
item will be sent to player B's world over the internet.
|
||||
|
||||
@@ -29,7 +29,7 @@ their game.
|
||||
|
||||
## What happens if a person has to leave early?
|
||||
|
||||
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all the
|
||||
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all the
|
||||
items in that game which belong to other players are sent out automatically, so other players can continue to play.
|
||||
|
||||
## What does multi-game mean?
|
||||
|
||||
6
WebHostLib/static/assets/lttpMultiTracker.js
Normal file
6
WebHostLib/static/assets/lttpMultiTracker.js
Normal file
@@ -0,0 +1,6 @@
|
||||
window.addEventListener('load', () => {
|
||||
$(".table-wrapper").scrollsync({
|
||||
y_sync: true,
|
||||
x_sync: true
|
||||
});
|
||||
});
|
||||
@@ -205,6 +205,11 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
let presetOption = document.createElement('option');
|
||||
presetOption.innerText = presetName;
|
||||
presetOption.value = settings[setting].value_names[presetName];
|
||||
const words = presetOption.innerText.split("_");
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
|
||||
}
|
||||
presetOption.innerText = words.join(" ");
|
||||
specialRangeSelect.appendChild(presetOption);
|
||||
});
|
||||
let customOption = document.createElement('option');
|
||||
|
||||
49
WebHostLib/static/assets/sc2wolTracker.js
Normal file
49
WebHostLib/static/assets/sc2wolTracker.js
Normal file
@@ -0,0 +1,49 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Reload tracker every 15 seconds
|
||||
const url = window.location;
|
||||
setInterval(() => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
|
||||
// Create a fake DOM using the returned HTML
|
||||
const domParser = new DOMParser();
|
||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||
|
||||
// Update item tracker
|
||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
||||
// Update only counters in the location-table
|
||||
let counters = document.getElementsByClassName('counter');
|
||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
||||
for (let i = 0; i < counters.length; i++) {
|
||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
||||
}
|
||||
};
|
||||
ajax.open('GET', url);
|
||||
ajax.send();
|
||||
}, 15000)
|
||||
|
||||
// Collapsible advancement sections
|
||||
const categories = document.getElementsByClassName("location-category");
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
let hide_id = categories[i].id.split('-')[0];
|
||||
if (hide_id == 'Total') {
|
||||
continue;
|
||||
}
|
||||
categories[i].addEventListener('click', function() {
|
||||
// Toggle the advancement list
|
||||
document.getElementById(hide_id).classList.toggle("hide");
|
||||
// Change text of the header
|
||||
const tab_header = document.getElementById(hide_id+'-header').children[0];
|
||||
const orig_text = tab_header.innerHTML;
|
||||
let new_text;
|
||||
if (orig_text.includes("▼")) {
|
||||
new_text = orig_text.replace("▼", "▲");
|
||||
}
|
||||
else {
|
||||
new_text = orig_text.replace("▲", "▼");
|
||||
}
|
||||
tab_header.innerHTML = new_text;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
const adjustTableHeight = () => {
|
||||
const tablesContainer = document.getElementById('tables-container');
|
||||
if (!tablesContainer)
|
||||
return;
|
||||
const upperDistance = tablesContainer.getBoundingClientRect().top;
|
||||
|
||||
const containerHeight = window.innerHeight - upperDistance;
|
||||
@@ -17,6 +19,14 @@ window.addEventListener('load', () => {
|
||||
paging: false,
|
||||
info: false,
|
||||
dom: "t",
|
||||
stateSave: true,
|
||||
stateSaveCallback: function(settings, data) {
|
||||
delete data.search;
|
||||
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
|
||||
},
|
||||
stateLoadCallback: function(settings) {
|
||||
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
|
||||
},
|
||||
columnDefs: [
|
||||
{
|
||||
targets: 'hours',
|
||||
@@ -63,16 +73,44 @@ window.addEventListener('load', () => {
|
||||
// the tbody and render two separate tables.
|
||||
});
|
||||
|
||||
document.getElementById('search').addEventListener('keyup', (event) => {
|
||||
tables.search(event.target.value);
|
||||
console.info(tables.search());
|
||||
const searchBox = document.getElementById("search");
|
||||
searchBox.value = tables.search();
|
||||
searchBox.focus();
|
||||
searchBox.select();
|
||||
const doSearch = () => {
|
||||
tables.search(searchBox.value);
|
||||
tables.draw();
|
||||
};
|
||||
searchBox.addEventListener("keyup", doSearch);
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (!event.ctrlKey && !event.altKey && event.key.length === 1 && document.activeElement !== searchBox) {
|
||||
searchBox.focus();
|
||||
searchBox.select();
|
||||
}
|
||||
if (!event.ctrlKey && !event.altKey && !event.shiftKey && event.key === "Escape") {
|
||||
if (searchBox.value !== "") {
|
||||
searchBox.value = "";
|
||||
doSearch();
|
||||
}
|
||||
searchBox.blur();
|
||||
if (!document.getElementById("tables-container"))
|
||||
window.scroll(0, 0);
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||
const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
|
||||
|
||||
function getSleepTimeSeconds(){
|
||||
// -40 % 60 is -40, which is absolutely wrong and should burn
|
||||
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
|
||||
return sleepSeconds || 60;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const target = $("<div></div>");
|
||||
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||
target.load("/tracker/" + tracker, function (response, status) {
|
||||
console.log("Updating Tracker...");
|
||||
target.load(location.href, function (response, status) {
|
||||
if (status === "success") {
|
||||
target.find(".table").each(function (i, new_table) {
|
||||
const new_trs = $(new_table).find("tbody>tr");
|
||||
@@ -90,19 +128,14 @@ window.addEventListener('load', () => {
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||
}
|
||||
|
||||
setInterval(update, 30000);
|
||||
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
adjustTableHeight();
|
||||
tables.draw();
|
||||
});
|
||||
|
||||
$(".table-wrapper").scrollsync({
|
||||
y_sync: true,
|
||||
x_sync: true
|
||||
});
|
||||
|
||||
adjustTableHeight();
|
||||
});
|
||||
@@ -6,6 +6,7 @@ window.addEventListener('load', () => {
|
||||
"order": [[ 3, "desc" ]],
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
$("#seeds-table").DataTable({
|
||||
"paging": false,
|
||||
@@ -13,5 +14,6 @@ window.addEventListener('load', () => {
|
||||
"order": [[ 2, "desc" ]],
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
});
|
||||
|
||||
BIN
WebHostLib/static/static/button-images/hamburger-menu-icon.png
Normal file
BIN
WebHostLib/static/static/button-images/hamburger-menu-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
30
WebHostLib/static/styles/checksfinderTracker.css
Normal file
30
WebHostLib/static/styles/checksfinderTracker.css
Normal file
@@ -0,0 +1,30 @@
|
||||
#player-tracker-wrapper{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#inventory-table{
|
||||
padding: 8px 10px 2px 6px;
|
||||
background-color: #42b149;
|
||||
border-radius: 4px;
|
||||
border: 2px solid black;
|
||||
}
|
||||
|
||||
#inventory-table tr.column-headers td {
|
||||
font-size: 1rem;
|
||||
padding: 0 5rem 0 0;
|
||||
}
|
||||
|
||||
#inventory-table td{
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
font-family: LexendDeca-Light, monospace;
|
||||
font-size: 2.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#inventory-table td img{
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
@@ -105,6 +105,9 @@ h5, h6{
|
||||
margin-bottom: 20px;
|
||||
background-color: #ffff00;
|
||||
}
|
||||
.user-message a{
|
||||
color: #ff7700;
|
||||
}
|
||||
|
||||
.interactive{
|
||||
color: #ffef00;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
padding: 3px 3px 10px;
|
||||
width: 448px;
|
||||
width: 480px;
|
||||
background-color: rgb(60, 114, 157);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
}
|
||||
|
||||
#location-table{
|
||||
width: 448px;
|
||||
width: 480px;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-bottom: 2px solid #000000;
|
||||
@@ -108,7 +108,7 @@
|
||||
}
|
||||
|
||||
#location-table td:first-child {
|
||||
width: 272px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.location-category td:first-child {
|
||||
|
||||
110
WebHostLib/static/styles/sc2wolTracker.css
Normal file
110
WebHostLib/static/styles/sc2wolTracker.css
Normal file
@@ -0,0 +1,110 @@
|
||||
#player-tracker-wrapper{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#inventory-table{
|
||||
border-top: 2px solid #000000;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
padding: 3px 3px 10px;
|
||||
width: 500px;
|
||||
background-color: #525494;
|
||||
}
|
||||
|
||||
#inventory-table td{
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#inventory-table td.title{
|
||||
padding-top: 10px;
|
||||
height: 20px;
|
||||
font-family: "JuraBook", monospace;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#inventory-table img{
|
||||
height: 100%;
|
||||
max-width: 40px;
|
||||
max-height: 40px;
|
||||
border: 1px solid #000000;
|
||||
filter: grayscale(100%) contrast(75%) brightness(20%);
|
||||
}
|
||||
|
||||
#inventory-table img.acquired{
|
||||
filter: none;
|
||||
}
|
||||
|
||||
#inventory-table div.counted-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#inventory-table div.item-count {
|
||||
text-align: left;
|
||||
color: black;
|
||||
font-family: "JuraBook", monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#location-table{
|
||||
width: 500px;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-bottom: 2px solid #000000;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
background-color: #525494;
|
||||
padding: 10px 3px 3px;
|
||||
font-family: "JuraBook", monospace;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#location-table th{
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
#location-table td{
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
#location-table td.counter {
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#location-table td.toggle-arrow {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#location-table tr#Total-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#location-table img{
|
||||
height: 100%;
|
||||
max-width: 30px;
|
||||
max-height: 30px;
|
||||
}
|
||||
|
||||
#location-table tbody.locations {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#location-table td.location-name {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ html{
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#base-header a{
|
||||
#base-header a, #base-header-mobile-menu a{
|
||||
color: #2f6b83;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
@@ -51,3 +51,64 @@ html{
|
||||
font-family: LondrinaSolid-Light, sans-serif;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#base-header-right-mobile{
|
||||
display: none;
|
||||
margin-top: 2rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
#base-header-mobile-menu{
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background-color: #ffffff;
|
||||
text-align: center;
|
||||
overflow-y: auto;
|
||||
z-index: 10000;
|
||||
width: 100vw;
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
|
||||
position: absolute;
|
||||
top: 7rem;
|
||||
right: 0;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
#base-header-mobile-menu a{
|
||||
padding: 4rem 2rem;
|
||||
font-size: 5rem;
|
||||
line-height: 5rem;
|
||||
color: #699ca8;
|
||||
border-top: 1px solid #d3d3d3;
|
||||
}
|
||||
|
||||
#base-header-right-mobile img{
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1580px){
|
||||
html{
|
||||
padding-top: 260px;
|
||||
scroll-padding-top: 230px;
|
||||
}
|
||||
|
||||
#base-header{
|
||||
height: 200px;
|
||||
background-size: auto 200px;
|
||||
}
|
||||
|
||||
#base-header #site-title img{
|
||||
height: calc(38px * 2);
|
||||
margin-top: 30px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
#base-header-right{
|
||||
display: none;
|
||||
}
|
||||
|
||||
#base-header-right-mobile{
|
||||
display: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,54 @@
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
padding: 3px 3px 10px;
|
||||
width: 384px;
|
||||
width: 374px;
|
||||
background-color: #8d60a7;
|
||||
}
|
||||
|
||||
#inventory-table td{
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
display: grid;
|
||||
grid-template-rows: repeat(5, 48px);
|
||||
}
|
||||
|
||||
#inventory-table img{
|
||||
display: block;
|
||||
}
|
||||
|
||||
#inventory-table div.table-row{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
#inventory-table div.C1{
|
||||
grid-column: 1;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
display: flex;
|
||||
}
|
||||
#inventory-table div.C2{
|
||||
grid-column: 2;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
display: flex;
|
||||
}
|
||||
#inventory-table div.C3{
|
||||
grid-column: 3;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
display: flex;
|
||||
}
|
||||
#inventory-table div.C4{
|
||||
grid-column: 4;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
display: flex;
|
||||
}
|
||||
#inventory-table div.C5{
|
||||
grid-column: 5;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#inventory-table img{
|
||||
height: 100%;
|
||||
max-width: 40px;
|
||||
max-height: 40px;
|
||||
filter: grayscale(100%) contrast(75%) brightness(30%);
|
||||
@@ -31,11 +66,70 @@
|
||||
filter: none;
|
||||
}
|
||||
|
||||
#inventory-table div.counted-item {
|
||||
#inventory-table img.acquired.purple{ /*00FFFF*/
|
||||
filter: hue-rotate(270deg) saturate(6) brightness(0.8);
|
||||
}
|
||||
#inventory-table img.acquired.cyan{ /*FF00FF*/
|
||||
filter: hue-rotate(138deg) saturate(10) brightness(0.8);
|
||||
}
|
||||
#inventory-table img.acquired.green{ /*32CD32*/
|
||||
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
|
||||
}
|
||||
|
||||
#inventory-table div.image-stack{
|
||||
display: grid;
|
||||
position: relative;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
#inventory-table div.image-stack div.stack-back{
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
#inventory-table div.image-stack div.stack-front{
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 20px 20px;
|
||||
grid-template-rows: 20px 20px;
|
||||
}
|
||||
|
||||
#inventory-table div.image-stack div.stack-top-left{
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#inventory-table div.image-stack div.stack-top-right{
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#inventory-table div.image-stack div.stack-bottum-left{
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#inventory-table div.image-stack div.stack-bottum-right{
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#inventory-table div.image-stack div.stack-front img{
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
#inventory-table div.counted-item{
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#inventory-table div.item-count {
|
||||
#inventory-table div.item-count{
|
||||
position: absolute;
|
||||
color: white;
|
||||
font-family: "Minecraftia", monospace;
|
||||
@@ -69,16 +163,16 @@
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
#location-table td.counter {
|
||||
#location-table td.counter{
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#location-table td.toggle-arrow {
|
||||
#location-table td.toggle-arrow{
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#location-table tr#Total-header {
|
||||
#location-table tr#Total-header{
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -88,14 +182,14 @@
|
||||
max-height: 30px;
|
||||
}
|
||||
|
||||
#location-table tbody.locations {
|
||||
#location-table tbody.locations{
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#location-table td.location-name {
|
||||
#location-table td.location-name{
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.hide {
|
||||
.hide{
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,33 @@ img.alttp-sprite {
|
||||
background-color: #d3c97d;
|
||||
}
|
||||
|
||||
#tracker-navigation {
|
||||
display: inline-flex;
|
||||
background-color: #b0a77d;
|
||||
margin: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tracker-navigation-button {
|
||||
display: block;
|
||||
margin: 4px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
font-weight: lighter;
|
||||
}
|
||||
|
||||
.tracker-navigation-button:hover {
|
||||
background-color: #e2eabb !important;
|
||||
}
|
||||
|
||||
.tracker-navigation-button.selected {
|
||||
background-color: rgb(220, 226, 189);
|
||||
}
|
||||
|
||||
@media all and (max-width: 1700px) {
|
||||
table.dataTable thead th.upper-row{
|
||||
position: -webkit-sticky;
|
||||
|
||||
@@ -22,7 +22,7 @@ def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str
|
||||
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
|
||||
games_played = defaultdict(Counter)
|
||||
total_games = Counter()
|
||||
cutoff = date.today()-timedelta(days=30)
|
||||
cutoff = date.today() - timedelta(days=30)
|
||||
room: Room
|
||||
for room in select(room for room in Room if room.creation_time >= cutoff):
|
||||
for slot in room.seed.slots:
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div id="check-result" class="grass-island">
|
||||
<h1>Verification Results</h1>
|
||||
<p>The results of your requested file check are below.</p>
|
||||
<table class="table autodatatable">
|
||||
<table id="results-table" class="table autodatatable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
|
||||
35
WebHostLib/templates/checksfinderTracker.html
Normal file
35
WebHostLib/templates/checksfinderTracker.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ player_name }}'s Tracker</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/checksfinderTracker.css') }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/checksfinderTracker.js') }}"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||
<table id="inventory-table">
|
||||
<tr class="column-headers">
|
||||
<td colspan="2">Checks Available:</td>
|
||||
<td colspan="2">Map Bombs:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img alt="Checks Available" src="{{ icons['Checks Available'] }}" /></td>
|
||||
<td>{{ checks_available }}</td>
|
||||
<td><img alt="Bombs Remaining" src="{{ icons['Map Bombs'] }}" /></td>
|
||||
<td>{{ bombs_display }}/20</td>
|
||||
</tr>
|
||||
<tr class="column-headers">
|
||||
<td colspan="2">Map Width:</td>
|
||||
<td colspan="2">Map Height:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img alt="Map Width" src="{{ icons['Map Width'] }}" /></td>
|
||||
<td>{{ width_display }}/10</td>
|
||||
<td><img alt="Map Height" src="{{ icons['Map Height'] }}" /></td>
|
||||
<td>{{ height_display }}/10</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -40,20 +40,20 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="forfeit_mode">Forfeit Permission:
|
||||
<span class="interactive" data-tooltip="A forfeit releases all remaining items from the locations in your world.">
|
||||
<label for="release_mode">Release Permission:
|
||||
<span class="interactive" data-tooltip="Permissions on when players are able to release all remaining items from their world.">
|
||||
(?)
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<select name="forfeit_mode" id="forfeit_mode">
|
||||
<select name="release_mode" id="release_mode">
|
||||
<option value="auto">Automatic on goal completion</option>
|
||||
<option value="goal">Allow !forfeit after goal completion</option>
|
||||
<option value="goal">Allow !release after goal completion</option>
|
||||
<option value="auto-enabled">
|
||||
Automatic on goal completion and manual !forfeit
|
||||
Automatic on goal completion and manual !release
|
||||
</option>
|
||||
<option value="enabled">Manual !forfeit</option>
|
||||
<option value="enabled">Manual !release</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -62,7 +62,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<label for="collect_mode">Collect Permission:
|
||||
<span class="interactive" data-tooltip="A collect releases all of your remaining items to you from across the multiworld.">
|
||||
<span class="interactive" data-tooltip="Permissions on when players are able to collect all their remaining items from across the multiworld.">
|
||||
(?)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
<title>{{ player_name }}'s Tracker</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/dirtHeader.html' %}
|
||||
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}">
|
||||
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}" data-second="{{ saving_second }}">
|
||||
<div id="tracker-header-bar">
|
||||
<input placeholder="Search" id="search"/>
|
||||
<span class="info">This tracker will automatically update itself periodically.</span>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="table non-unique-item-table">
|
||||
<table id="received-table" class="table non-unique-item-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
@@ -37,7 +37,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="table non-unique-item-table">
|
||||
<table id="locations-table" class="table non-unique-item-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Location</th>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% block head %}
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/themes/base.css") }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/baseHeader.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
@@ -16,5 +17,17 @@
|
||||
<a href="/faq/en">f.a.q.</a>
|
||||
<a href="https://discord.gg/8Z65BR2" target="_blank">discord</a>
|
||||
</div>
|
||||
<div id="base-header-right-mobile">
|
||||
<a id="base-header-mobile-menu-button" href="#">
|
||||
<img src="/static/static/button-images/hamburger-menu-icon.png" alt="Menu" />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<div id="base-header-mobile-menu">
|
||||
<a href="/games">supported games</a>
|
||||
<a href="/tutorial">setup guides</a>
|
||||
<a href="/start-playing">start playing</a>
|
||||
<a href="/faq/en">f.a.q.</a>
|
||||
<a href="https://discord.gg/8Z65BR2" target="_blank">discord</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if room.tracker %}
|
||||
This room has a <a href="{{ url_for("getTracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
|
||||
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
|
||||
<br />
|
||||
{% endif %}
|
||||
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
|
||||
@@ -25,8 +25,8 @@
|
||||
The most likely failure reason is that the multiworld is too old to be loaded now.
|
||||
{% elif room.last_port %}
|
||||
You can connect to this room by using <span class="interactive"
|
||||
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
|
||||
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
|
||||
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
|
||||
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
|
||||
</span>
|
||||
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
{% extends 'tablepage.html' %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>Multiworld Tracker</title>
|
||||
<title>ALttP Multiworld Tracker</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttpMultiTracker.js") }}"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/dirtHeader.html' %}
|
||||
{% include 'multiTrackerNavigation.html' %}
|
||||
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||
<div id="tracker-header-bar">
|
||||
<input placeholder="Search" id="search"/>
|
||||
@@ -25,7 +27,7 @@
|
||||
<div id="tables-container">
|
||||
{% for team, players in inventory.items() %}
|
||||
<div class="table-wrapper">
|
||||
<table class="table unique-item-table">
|
||||
<table id="inventory-table" class="table unique-item-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
@@ -44,7 +46,7 @@
|
||||
<tbody>
|
||||
{%- for player, items in players.items() -%}
|
||||
<tr>
|
||||
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
|
||||
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
|
||||
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
||||
{%- if (team, loop.index) in video -%}
|
||||
{%- if video[(team, loop.index)][0] == "Twitch" -%}
|
||||
@@ -78,7 +80,7 @@
|
||||
|
||||
{% for team, players in checks_done.items() %}
|
||||
<div class="table-wrapper">
|
||||
<table class="table non-unique-item-table">
|
||||
<table id="checks-table" class="table non-unique-item-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">#</th>
|
||||
@@ -98,6 +100,7 @@
|
||||
<th colspan="{{ colspan }}" class="center-column">{{ area }}</th>
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
<th rowspan="2" class="center-column">%</th>
|
||||
<th rowspan="2" class="center-column hours">Last<br>Activity</th>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -121,7 +124,7 @@
|
||||
<tbody>
|
||||
{%- for player, checks in players.items() -%}
|
||||
<tr>
|
||||
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
|
||||
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
|
||||
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
||||
<td>{{ player_names[(team, loop.index)]|e }}</td>
|
||||
{%- for area in ordered_areas -%}
|
||||
@@ -140,6 +143,7 @@
|
||||
<td class="center-column">{% if inventory[team][player][big_key_ids[area]] %}✔️{% endif %}</td>
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
|
||||
{%- if activity_timers[(team, player)] -%}
|
||||
<td class="center-column">{{ activity_timers[(team, player)].total_seconds() }}</td>
|
||||
{%- else -%}
|
||||
@@ -153,7 +157,7 @@
|
||||
{% endfor %}
|
||||
{% for team, hints in hints.items() %}
|
||||
<div class="table-wrapper">
|
||||
<table class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
|
||||
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Finder</th>
|
||||
@@ -22,7 +22,7 @@
|
||||
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
|
||||
<tr>
|
||||
<td>{{ patch.player_id }}</td>
|
||||
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['PATCH_TARGET'] }}:{{ room.last_port }}">{{ patch.player_name }}<a/></td>
|
||||
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
|
||||
<td>{{ patch.game }}</td>
|
||||
<td>
|
||||
{% if patch.game == "Minecraft" %}
|
||||
@@ -50,7 +50,7 @@
|
||||
No file to download for this game.
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td>
|
||||
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
44
WebHostLib/templates/multiFactorioTracker.html
Normal file
44
WebHostLib/templates/multiFactorioTracker.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% extends "multiTracker.html" %}
|
||||
{% block custom_table_headers %}
|
||||
<th class="center-column">
|
||||
<img src="https://wiki.factorio.com/images/thumb/Logistic_science_pack.png/32px-Logistic_science_pack.png"
|
||||
alt="Logistic Science Pack">
|
||||
</th>
|
||||
<th class="center-column">
|
||||
<img src="https://wiki.factorio.com/images/thumb/Military_science_pack.png/32px-Military_science_pack.png"
|
||||
alt="Military Science Pack">
|
||||
</th>
|
||||
<th class="center-column">
|
||||
<img src="https://wiki.factorio.com/images/thumb/Chemical_science_pack.png/32px-Chemical_science_pack.png"
|
||||
alt="Chemical Science Pack">
|
||||
</th>
|
||||
<th class="center-column">
|
||||
<img src="https://wiki.factorio.com/images/thumb/Production_science_pack.png/32px-Production_science_pack.png"
|
||||
alt="Production Science Pack">
|
||||
</th>
|
||||
<th class="center-column">
|
||||
<img src="https://wiki.factorio.com/images/thumb/Utility_science_pack.png/32px-Utility_science_pack.png"
|
||||
alt="Utility Science Pack">
|
||||
</th>
|
||||
<th class="center-column">
|
||||
<img src="https://wiki.factorio.com/images/thumb/Space_science_pack.png/32px-Space_science_pack.png"
|
||||
alt="Space Science Pack">
|
||||
</th>
|
||||
{% endblock %}
|
||||
{% block custom_table_row scoped %}
|
||||
{% if games[player] == "Factorio" %}
|
||||
<td class="center-column">{% if inventory[team][player][131161] or inventory[team][player][131281] %}✔{% endif %}</td>
|
||||
<td class="center-column">{% if inventory[team][player][131172] or inventory[team][player][131281] > 1%}✔{% endif %}</td>
|
||||
<td class="center-column">{% if inventory[team][player][131195] or inventory[team][player][131281] > 2%}✔{% endif %}</td>
|
||||
<td class="center-column">{% if inventory[team][player][131240] or inventory[team][player][131281] > 3%}✔{% endif %}</td>
|
||||
<td class="center-column">{% if inventory[team][player][131240] or inventory[team][player][131281] > 4%}✔{% endif %}</td>
|
||||
<td class="center-column">{% if inventory[team][player][131220] or inventory[team][player][131281] > 5%}✔{% endif %}</td>
|
||||
{% else %}
|
||||
<td class="center-column">❌</td>
|
||||
<td class="center-column">❌</td>
|
||||
<td class="center-column">❌</td>
|
||||
<td class="center-column">❌</td>
|
||||
<td class="center-column">❌</td>
|
||||
<td class="center-column">❌</td>
|
||||
{% endif %}
|
||||
{% endblock%}
|
||||
95
WebHostLib/templates/multiTracker.html
Normal file
95
WebHostLib/templates/multiTracker.html
Normal file
@@ -0,0 +1,95 @@
|
||||
{% extends 'tablepage.html' %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<title>Multiworld Tracker</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/dirtHeader.html' %}
|
||||
{% include 'multiTrackerNavigation.html' %}
|
||||
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||
<div id="tracker-header-bar">
|
||||
<input placeholder="Search" id="search"/>
|
||||
<span{% if not video %} hidden{% endif %} id="multi-stream-link">
|
||||
<a target="_blank" href="https://multistream.me/
|
||||
{%- for platform, link in video.values()|unique(False, 1)-%}
|
||||
{%- if platform == "Twitch" -%}t{%- else -%}yt{%- endif -%}:{{- link -}}/
|
||||
{%- endfor -%}">
|
||||
Multistream
|
||||
</a>
|
||||
</span>
|
||||
<span class="info">Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically.</span>
|
||||
</div>
|
||||
<div id="tables-container">
|
||||
{% for team, players in checks_done.items() %}
|
||||
<div class="table-wrapper">
|
||||
<table id="checks-table" class="table non-unique-item-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th>Game</th>
|
||||
{% block custom_table_headers %}
|
||||
{# implement this block in game-specific multi trackers #}
|
||||
{% endblock %}
|
||||
<th class="center-column">Checks</th>
|
||||
<th class="center-column">%</th>
|
||||
<th class="center-column hours">Last<br>Activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{%- for player, checks in players.items() -%}
|
||||
<tr>
|
||||
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
|
||||
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
|
||||
<td>{{ player_names[(team, loop.index)]|e }}</td>
|
||||
<td>{{ games[player] }}</td>
|
||||
{% block custom_table_row scoped %}
|
||||
{# implement this block in game-specific multi trackers #}
|
||||
{% endblock %}
|
||||
<td class="center-column">{{ checks["Total"] }}/{{ checks_in_area[player]["Total"] }}</td>
|
||||
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
|
||||
{%- if activity_timers[(team, player)] -%}
|
||||
<td class="center-column">{{ activity_timers[(team, player)].total_seconds() }}</td>
|
||||
{%- else -%}
|
||||
<td class="center-column">None</td>
|
||||
{%- endif -%}
|
||||
</tr>
|
||||
{%- endfor -%}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for team, hints in hints.items() %}
|
||||
<div class="table-wrapper">
|
||||
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Finder</th>
|
||||
<th>Receiver</th>
|
||||
<th>Item</th>
|
||||
<th>Location</th>
|
||||
<th>Entrance</th>
|
||||
<th>Found</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{%- for hint in hints -%}
|
||||
<tr>
|
||||
<td>{{ long_player_names[team, hint.finding_player] }}</td>
|
||||
<td>{{ long_player_names[team, hint.receiving_player] }}</td>
|
||||
<td>{{ hint.item|item_name }}</td>
|
||||
<td>{{ hint.location|location_name }}</td>
|
||||
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
|
||||
<td>{% if hint.found %}✔{% endif %}</td>
|
||||
</tr>
|
||||
{%- endfor -%}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
9
WebHostLib/templates/multiTrackerNavigation.html
Normal file
9
WebHostLib/templates/multiTrackerNavigation.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{%- if enabled_multiworld_trackers|length > 1 -%}
|
||||
<div id="tracker-navigation">
|
||||
{% for enabled_tracker in enabled_multiworld_trackers %}
|
||||
{% set tracker_url = url_for(enabled_tracker.endpoint, tracker=room.tracker) %}
|
||||
<a class="tracker-navigation-button{%- if enabled_tracker.current -%} selected{% endif %}"
|
||||
href="{{ tracker_url }}">{{ enabled_tracker.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
233
WebHostLib/templates/sc2wolTracker.html
Normal file
233
WebHostLib/templates/sc2wolTracker.html
Normal file
@@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ player_name }}'s Tracker</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/sc2wolTracker.css') }}"/>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/sc2wolTracker.js') }}"></script>
|
||||
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/jura" type="text/css"/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||
<table id="inventory-table">
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Starting Resources
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Starting Minerals'] }}" class="{{ 'acquired' if '+15 Starting Minerals' in acquired_items }}" title="Starting Minerals" /></td>
|
||||
<td colspan="2"><div class="item-count">+{{ minerals_count }}</div></td>
|
||||
<td><img src="{{ icons['Starting Vespene'] }}" class="{{ 'acquired' if '+15 Starting Vespene' in acquired_items }}" title="Starting Vespene" /></td>
|
||||
<td colspan="2"><div class="item-count">+{{ vespene_count }}</div></td>
|
||||
<!--
|
||||
<td><img src="{{ icons['Starting Supply'] }}" class="{{ 'acquired' if '+2 Starting Supply' in acquired_items }}" title="Starting Supply" /></td>
|
||||
<td colspan="2"><div class="item-count">+{{ supply_count }}</div></td>
|
||||
-->
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Weapon & Armor Upgrades
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ infantry_weapon_url }}" class="{{ 'acquired' if 'Progressive Infantry Weapon' in acquired_items }}" title="Progressive Infantry Weapons{% if infantry_weapon_level > 0 %} (Level {{ infantry_weapon_level }}){% endif %}" /></td>
|
||||
<td><img src="{{ infantry_armor_url }}" class="{{ 'acquired' if 'Progressive Infantry Armor' in acquired_items }}" title="Progressive Infantry Armor{% if infantry_armor_level > 0 %} (Level {{ infantry_armor_level }}){% endif %}" /></td>
|
||||
<td><img src="{{ vehicle_weapon_url }}" class="{{ 'acquired' if 'Progressive Vehicle Weapon' in acquired_items }}" title="Progressive Vehicle Weapons{% if vehicle_weapon_level > 0 %} (Level {{ vehicle_weapon_level }}){% endif %}" /></td>
|
||||
<td><img src="{{ vehicle_armor_url }}" class="{{ 'acquired' if 'Progressive Vehicle Armor' in acquired_items }}" title="Progressive Vehicle Armor{% if vehicle_armor_level > 0 %} (Level {{ vehicle_armor_level }}){% endif %}" /></td>
|
||||
<td><img src="{{ ship_weapon_url }}" class="{{ 'acquired' if 'Progressive Ship Weapon' in acquired_items }}" title="Progressive Ship Weapons{% if ship_weapon_level > 0 %} (Level {{ ship_weapon_level }}){% endif %}" /></td>
|
||||
<td><img src="{{ ship_armor_url }}" class="{{ 'acquired' if 'Progressive Ship Armor' in acquired_items }}" title="Progressive Ship Armor{% if ship_armor_level > 0 %} (Level {{ ship_armor_level }}){% endif %}" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Base
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><img src="{{ icons['Bunker'] }}" class="{{ 'acquired' if 'Bunker' in acquired_items }}" title="Bunker" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Missile Turret'] }}" class="{{ 'acquired' if 'Missile Turret' in acquired_items }}" title="Missile Turret" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Sensor Tower'] }}" class="{{ 'acquired' if 'Sensor Tower' in acquired_items }}" title="Sensor Tower" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Projectile Accelerator (Bunker)'] }}" class="{{ 'acquired' if 'Projectile Accelerator (Bunker)' in acquired_items }}" title="Projectile Accelerator (Bunker)" /></td>
|
||||
<td><img src="{{ icons['Neosteel Bunker (Bunker)'] }}" class="{{ 'acquired' if 'Neosteel Bunker (Bunker)' in acquired_items }}" title="Neosteel Bunker (Bunker)" /></td>
|
||||
<td><img src="{{ icons['Titanium Housing (Missile Turret)'] }}" class="{{ 'acquired' if 'Titanium Housing (Missile Turret)' in acquired_items }}" title="Titanium Housing (Missile Turret)" /></td>
|
||||
<td><img src="{{ icons['Hellstorm Batteries (Missile Turret)'] }}" class="{{ 'acquired' if 'Hellstorm Batteries (Missile Turret)' in acquired_items }}" title="Hellstorm Batteries (Missile Turret)" /></td>
|
||||
<td colspan="2"> </td>
|
||||
<td><img src="{{ icons['Advanced Construction (SCV)'] }}" class="{{ 'acquired' if 'Advanced Construction (SCV)' in acquired_items }}" title="Advanced Construction (SCV)" /></td>
|
||||
<td><img src="{{ icons['Dual-Fusion Welders (SCV)'] }}" class="{{ 'acquired' if 'Dual-Fusion Welders (SCV)' in acquired_items }}" title="Dual-Fusion Welders (SCV)" /></td>
|
||||
<td><img src="{{ icons['Fire-Suppression System (Building)'] }}" class="{{ 'acquired' if 'Fire-Suppression System (Building)' in acquired_items }}" title="Fire-Suppression System (Building)" /></td>
|
||||
<td><img src="{{ icons['Orbital Command (Building)'] }}" class="{{ 'acquired' if 'Orbital Command (Building)' in acquired_items }}" title="Orbital Command (Building)" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Infantry
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><img src="{{ icons['Marine'] }}" class="{{ 'acquired' if 'Marine' in acquired_items }}" title="Marine" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Medic'] }}" class="{{ 'acquired' if 'Medic' in acquired_items }}" title="Medic" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Firebat'] }}" class="{{ 'acquired' if 'Firebat' in acquired_items }}" title="Firebat" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Marauder'] }}" class="{{ 'acquired' if 'Marauder' in acquired_items }}" title="Marauder" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Reaper'] }}" class="{{ 'acquired' if 'Reaper' in acquired_items }}" title="Reaper" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Stimpack (Marine)'] }}" class="{{ 'acquired' if 'Stimpack (Marine)' in acquired_items }}" title="Stimpack (Marine)" /></td>
|
||||
<td><img src="{{ icons['Combat Shield (Marine)'] }}" class="{{ 'acquired' if 'Combat Shield (Marine)' in acquired_items }}" title="Combat Shield (Marine)" /></td>
|
||||
<td><img src="{{ icons['Advanced Medic Facilities (Medic)'] }}" class="{{ 'acquired' if 'Advanced Medic Facilities (Medic)' in acquired_items }}" title="Advanced Medic Facilities (Medic)" /></td>
|
||||
<td><img src="{{ icons['Stabilizer Medpacks (Medic)'] }}" class="{{ 'acquired' if 'Stabilizer Medpacks (Medic)' in acquired_items }}" title="Stabilizer Medpacks (Medic)" /></td>
|
||||
<td><img src="{{ icons['Incinerator Gauntlets (Firebat)'] }}" class="{{ 'acquired' if 'Incinerator Gauntlets (Firebat)' in acquired_items }}" title="Incinerator Gauntlets (Firebat)" /></td>
|
||||
<td><img src="{{ icons['Juggernaut Plating (Firebat)'] }}" class="{{ 'acquired' if 'Juggernaut Plating (Firebat)' in acquired_items }}" title="Juggernaut Plating (Firebat)" /></td>
|
||||
<td><img src="{{ icons['Concussive Shells (Marauder)'] }}" class="{{ 'acquired' if 'Concussive Shells (Marauder)' in acquired_items }}" title="Concussive Shells (Marauder)" /></td>
|
||||
<td><img src="{{ icons['Kinetic Foam (Marauder)'] }}" class="{{ 'acquired' if 'Kinetic Foam (Marauder)' in acquired_items }}" title="Kinetic Foam (Marauder)" /></td>
|
||||
<td><img src="{{ icons['U-238 Rounds (Reaper)'] }}" class="{{ 'acquired' if 'U-238 Rounds (Reaper)' in acquired_items }}" title="U-238 Rounds (Reaper)" /></td>
|
||||
<td><img src="{{ icons['G-4 Clusterbomb (Reaper)'] }}" class="{{ 'acquired' if 'G-4 Clusterbomb (Reaper)' in acquired_items }}" title="G-4 Clusterbomb (Reaper)" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Vehicles
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><img src="{{ icons['Hellion'] }}" class="{{ 'acquired' if 'Hellion' in acquired_items }}" title="Hellion" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Vulture'] }}" class="{{ 'acquired' if 'Vulture' in acquired_items }}" title="Vulture" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Goliath'] }}" class="{{ 'acquired' if 'Goliath' in acquired_items }}" title="Goliath" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Diamondback'] }}" class="{{ 'acquired' if 'Diamondback' in acquired_items }}" title="Diamondback" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Siege Tank'] }}" class="{{ 'acquired' if 'Siege Tank' in acquired_items }}" title="Siege Tank" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Twin-Linked Flamethrower (Hellion)'] }}" class="{{ 'acquired' if 'Twin-Linked Flamethrower (Hellion)' in acquired_items }}" title="Twin-Linked Flamethrower (Hellion)" /></td>
|
||||
<td><img src="{{ icons['Thermite Filaments (Hellion)'] }}" class="{{ 'acquired' if 'Thermite Filaments (Hellion)' in acquired_items }}" title="Thermite Filaments (Hellion)" /></td>
|
||||
<td><img src="{{ icons['Cerberus Mine (Vulture)'] }}" class="{{ 'acquired' if 'Cerberus Mine (Vulture)' in acquired_items }}" title="Cerberus Mine (Vulture)" /></td>
|
||||
<td><img src="{{ icons['Replenishable Magazine (Vulture)'] }}" class="{{ 'acquired' if 'Replenishable Magazine (Vulture)' in acquired_items }}" title="Replenishable Magazine (Vulture)" /></td>
|
||||
<td><img src="{{ icons['Multi-Lock Weapons System (Goliath)'] }}" class="{{ 'acquired' if 'Multi-Lock Weapons System (Goliath)' in acquired_items }}" title="Multi-Lock Weapons System (Goliath)" /></td>
|
||||
<td><img src="{{ icons['Ares-Class Targeting System (Goliath)'] }}" class="{{ 'acquired' if 'Ares-Class Targeting System (Goliath)' in acquired_items }}" title="Ares-Class Targeting System (Goliath)" /></td>
|
||||
<td><img src="{{ icons['Tri-Lithium Power Cell (Diamondback)'] }}" class="{{ 'acquired' if 'Tri-Lithium Power Cell (Diamondback)' in acquired_items }}" title="Tri-Lithium Power Cell (Diamondback)" /></td>
|
||||
<td><img src="{{ icons['Shaped Hull (Diamondback)'] }}" class="{{ 'acquired' if 'Shaped Hull (Diamondback)' in acquired_items }}" title="Shaped Hull (Diamondback)" /></td>
|
||||
<td><img src="{{ icons['Maelstrom Rounds (Siege Tank)'] }}" class="{{ 'acquired' if 'Maelstrom Rounds (Siege Tank)' in acquired_items }}" title="Maelstrom Rounds (Siege Tank)" /></td>
|
||||
<td><img src="{{ icons['Shaped Blast (Siege Tank)'] }}" class="{{ 'acquired' if 'Shaped Blast (Siege Tank)' in acquired_items }}" title="Shaped Blast (Siege Tank)" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Starships
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><img src="{{ icons['Medivac'] }}" class="{{ 'acquired' if 'Medivac' in acquired_items }}" title="Medivac" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Wraith'] }}" class="{{ 'acquired' if 'Wraith' in acquired_items }}" title="Wraith" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Viking'] }}" class="{{ 'acquired' if 'Viking' in acquired_items }}" title="Viking" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Banshee'] }}" class="{{ 'acquired' if 'Banshee' in acquired_items }}" title="Banshee" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Battlecruiser'] }}" class="{{ 'acquired' if 'Battlecruiser' in acquired_items }}" title="Battlecruiser" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Rapid Deployment Tube (Medivac)'] }}" class="{{ 'acquired' if 'Rapid Deployment Tube (Medivac)' in acquired_items }}" title="Rapid Deployment Tube (Medivac)" /></td>
|
||||
<td><img src="{{ icons['Advanced Healing AI (Medivac)'] }}" class="{{ 'acquired' if 'Advanced Healing AI (Medivac)' in acquired_items }}" title="Advanced Healing AI (Medivac)" /></td>
|
||||
<td><img src="{{ icons['Tomahawk Power Cells (Wraith)'] }}" class="{{ 'acquired' if 'Tomahawk Power Cells (Wraith)' in acquired_items }}" title="Tomahawk Power Cells (Wraith)" /></td>
|
||||
<td><img src="{{ icons['Displacement Field (Wraith)'] }}" class="{{ 'acquired' if 'Displacement Field (Wraith)' in acquired_items }}" title="Displacement Field (Wraith)" /></td>
|
||||
<td><img src="{{ icons['Ripwave Missiles (Viking)'] }}" class="{{ 'acquired' if 'Ripwave Missiles (Viking)' in acquired_items }}" title="Ripwave Missiles (Viking)" /></td>
|
||||
<td><img src="{{ icons['Phobos-Class Weapons System (Viking)'] }}" class="{{ 'acquired' if 'Phobos-Class Weapons System (Viking)' in acquired_items }}" title="Phobos-Class Weapons System (Viking)" /></td>
|
||||
<td><img src="{{ icons['Cross-Spectrum Dampeners (Banshee)'] }}" class="{{ 'acquired' if 'Cross-Spectrum Dampeners (Banshee)' in acquired_items }}" title="Cross-Spectrum Dampeners (Banshee)" /></td>
|
||||
<td><img src="{{ icons['Shockwave Missile Battery (Banshee)'] }}" class="{{ 'acquired' if 'Shockwave Missile Battery (Banshee)' in acquired_items }}" title="Shockwave Missile Battery (Banshee)" /></td>
|
||||
<td><img src="{{ icons['Missile Pods (Battlecruiser)'] }}" class="{{ 'acquired' if 'Missile Pods (Battlecruiser)' in acquired_items }}" title="Missile Pods (Battlecruiser)" /></td>
|
||||
<td><img src="{{ icons['Defensive Matrix (Battlecruiser)'] }}" class="{{ 'acquired' if 'Defensive Matrix (Battlecruiser)' in acquired_items }}" title="Defensive Matrix (Battlecruiser)" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Dominion
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><img src="{{ icons['Ghost'] }}" class="{{ 'acquired' if 'Ghost' in acquired_items }}" title="Ghost" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Spectre'] }}" class="{{ 'acquired' if 'Spectre' in acquired_items }}" title="Spectre" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Thor'] }}" class="{{ 'acquired' if 'Thor' in acquired_items }}" title="Thor" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Ocular Implants (Ghost)'] }}" class="{{ 'acquired' if 'Ocular Implants (Ghost)' in acquired_items }}" title="Ocular Implants (Ghost)" /></td>
|
||||
<td><img src="{{ icons['Crius Suit (Ghost)'] }}" class="{{ 'acquired' if 'Crius Suit (Ghost)' in acquired_items }}" title="Crius Suit (Ghost)" /></td>
|
||||
<td><img src="{{ icons['Psionic Lash (Spectre)'] }}" class="{{ 'acquired' if 'Psionic Lash (Spectre)' in acquired_items }}" title="Psionic Lash (Spectre)" /></td>
|
||||
<td><img src="{{ icons['Nyx-Class Cloaking Module (Spectre)'] }}" class="{{ 'acquired' if 'Nyx-Class Cloaking Module (Spectre)' in acquired_items }}" title="Nyx-Class Cloaking Module (Spectre)" /></td>
|
||||
<td><img src="{{ icons['330mm Barrage Cannon (Thor)'] }}" class="{{ 'acquired' if '330mm Barrage Cannon (Thor)' in acquired_items }}" title="330mm Barrage Cannon (Thor)" /></td>
|
||||
<td><img src="{{ icons['Immortality Protocol (Thor)'] }}" class="{{ 'acquired' if 'Immortality Protocol (Thor)' in acquired_items }}" title="Immortality Protocol (Thor)" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Mercenaries
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['War Pigs'] }}" class="{{ 'acquired' if 'War Pigs' in acquired_items }}" title="War Pigs" /></td>
|
||||
<td><img src="{{ icons['Devil Dogs'] }}" class="{{ 'acquired' if 'Devil Dogs' in acquired_items }}" title="Devil Dogs" /></td>
|
||||
<td><img src="{{ icons['Hammer Securities'] }}" class="{{ 'acquired' if 'Hammer Securities' in acquired_items }}" title="Hammer Securities" /></td>
|
||||
<td><img src="{{ icons['Spartan Company'] }}" class="{{ 'acquired' if 'Spartan Company' in acquired_items }}" title="Spartan Company" /></td>
|
||||
<td><img src="{{ icons['Siege Breakers'] }}" class="{{ 'acquired' if 'Siege Breakers' in acquired_items }}" title="Siege Breakers" /></td>
|
||||
<td><img src="{{ icons['Hel\'s Angel'] }}" class="{{ 'acquired' if 'Hel\'s Angel' in acquired_items }}" title="Hel's Angel" /></td>
|
||||
<td><img src="{{ icons['Dusk Wings'] }}" class="{{ 'acquired' if 'Dusk Wings' in acquired_items }}" title="Dusk Wings" /></td>
|
||||
<td><img src="{{ icons['Jackson\'s Revenge'] }}" class="{{ 'acquired' if 'Jackson\'s Revenge' in acquired_items }}" title="Jackson's Revenge" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Lab Upgrades
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Ultra-Capacitors'] }}" class="{{ 'acquired' if 'Ultra-Capacitors' in acquired_items }}" title="Ultra-Capacitors" /></td>
|
||||
<td><img src="{{ icons['Vanadium Plating'] }}" class="{{ 'acquired' if 'Vanadium Plating' in acquired_items }}" title="Vanadium Plating" /></td>
|
||||
<td><img src="{{ icons['Orbital Depots'] }}" class="{{ 'acquired' if 'Orbital Depots' in acquired_items }}" title="Orbital Depots" /></td>
|
||||
<td><img src="{{ icons['Micro-Filtering'] }}" class="{{ 'acquired' if 'Micro-Filtering' in acquired_items }}" title="Micro-Filtering" /></td>
|
||||
<td><img src="{{ icons['Automated Refinery'] }}" class="{{ 'acquired' if 'Automated Refinery' in acquired_items }}" title="Automated Refinery" /></td>
|
||||
<td><img src="{{ icons['Command Center Reactor'] }}" class="{{ 'acquired' if 'Command Center Reactor' in acquired_items }}" title="Command Center Reactor" /></td>
|
||||
<td><img src="{{ icons['Raven'] }}" class="{{ 'acquired' if 'Raven' in acquired_items }}" title="Raven" /></td>
|
||||
<td><img src="{{ icons['Science Vessel'] }}" class="{{ 'acquired' if 'Science Vessel' in acquired_items }}" title="Science Vessel" /></td>
|
||||
<td><img src="{{ icons['Tech Reactor'] }}" class="{{ 'acquired' if 'Tech Reactor' in acquired_items }}" title="Tech Reactor" /></td>
|
||||
<td><img src="{{ icons['Orbital Strike'] }}" class="{{ 'acquired' if 'Orbital Strike' in acquired_items }}" title="Orbital Strike" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Shrike Turret'] }}" class="{{ 'acquired' if 'Shrike Turret' in acquired_items }}" title="Shrike Turret" /></td>
|
||||
<td><img src="{{ icons['Fortified Bunker'] }}" class="{{ 'acquired' if 'Fortified Bunker' in acquired_items }}" title="Fortified Bunker" /></td>
|
||||
<td><img src="{{ icons['Planetary Fortress'] }}" class="{{ 'acquired' if 'Planetary Fortress' in acquired_items }}" title="Planetary Fortress" /></td>
|
||||
<td><img src="{{ icons['Perdition Turret'] }}" class="{{ 'acquired' if 'Perdition Turret' in acquired_items }}" title="Perdition Turret" /></td>
|
||||
<td><img src="{{ icons['Predator'] }}" class="{{ 'acquired' if 'Predator' in acquired_items }}" title="Predator" /></td>
|
||||
<td><img src="{{ icons['Hercules'] }}" class="{{ 'acquired' if 'Hercules' in acquired_items }}" title="Hercules" /></td>
|
||||
<td><img src="{{ icons['Cellular Reactor'] }}" class="{{ 'acquired' if 'Cellular Reactor' in acquired_items }}" title="Cellular Reactor" /></td>
|
||||
<td><img src="{{ icons['Regenerative Bio-Steel'] }}" class="{{ 'acquired' if 'Regenerative Bio-Steel' in acquired_items }}" title="Regenerative Bio-Steel" /></td>
|
||||
<td><img src="{{ icons['Hive Mind Emulator'] }}" class="{{ 'acquired' if 'Hive Mind Emulator' in acquired_items }}" title="Hive Mind Emulator" /></td>
|
||||
<td><img src="{{ icons['Psi Disrupter'] }}" class="{{ 'acquired' if 'Psi Disrupter' in acquired_items }}" title="Psi Disrupter" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Protoss Units
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Zealot'] }}" class="{{ 'acquired' if 'Zealot' in acquired_items }}" title="Zealot" /></td>
|
||||
<td><img src="{{ icons['Stalker'] }}" class="{{ 'acquired' if 'Stalker' in acquired_items }}" title="Stalker" /></td>
|
||||
<td><img src="{{ icons['High Templar'] }}" class="{{ 'acquired' if 'High Templar' in acquired_items }}" title="High Templar" /></td>
|
||||
<td><img src="{{ icons['Dark Templar'] }}" class="{{ 'acquired' if 'Dark Templar' in acquired_items }}" title="Dark Templar" /></td>
|
||||
<td><img src="{{ icons['Immortal'] }}" class="{{ 'acquired' if 'Immortal' in acquired_items }}" title="Immortal" /></td>
|
||||
<td><img src="{{ icons['Colossus'] }}" class="{{ 'acquired' if 'Colossus' in acquired_items }}" title="Colossus" /></td>
|
||||
<td><img src="{{ icons['Phoenix'] }}" class="{{ 'acquired' if 'Phoenix' in acquired_items }}" title="Phoenix" /></td>
|
||||
<td><img src="{{ icons['Void Ray'] }}" class="{{ 'acquired' if 'Void Ray' in acquired_items }}" title="Void Ray" /></td>
|
||||
<td><img src="{{ icons['Carrier'] }}" class="{{ 'acquired' if 'Carrier' in acquired_items }}" title="Carrier" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
<table id="location-table">
|
||||
{% for area in checks_in_area %}
|
||||
{% if checks_in_area[area] > 0 %}
|
||||
<tr class="location-category" id="{{area}}-header">
|
||||
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
|
||||
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
|
||||
</tr>
|
||||
<tbody class="locations hide" id="{{area}}">
|
||||
{% for location in location_info[area] %}
|
||||
<tr>
|
||||
<td class="location-name">{{ location }}</td>
|
||||
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -29,17 +29,30 @@
|
||||
<li><a href="/glossary/en">Glossary</a></li>
|
||||
</ul>
|
||||
|
||||
<h2>Tutorials</h2>
|
||||
<ul>
|
||||
<li><a href="/tutorial/Archipelago/setup/en">Multiworld Setup Tutorial</a></li>
|
||||
<li><a href="/tutorial/Archipelago/using_website/en">Website User Guide</a></li>
|
||||
<li><a href="/tutorial/Archipelago/mac/en">Setup Guide for Mac</a></li>
|
||||
<li><a href="/tutorial/Archipelago/commands/en">Server and Client Commands</a></li>
|
||||
<li><a href="/tutorial/Archipelago/advanced_settings/en">Advanced YAML Guide</a></li>
|
||||
<li><a href="/tutorial/Archipelago/triggers/en">Triggers Guide</a></li>
|
||||
<li><a href="/tutorial/Archipelago/plando/en">Plando Guide</a></li>
|
||||
</ul>
|
||||
|
||||
<h2>Game Info Pages</h2>
|
||||
<ul>
|
||||
{% for game in games %}
|
||||
<li><a href="{{ url_for('game_info', game=game, lang='en') }}">{{ game }}</a></li>
|
||||
{% for game in games | title_sorted %}
|
||||
<li><a href="{{ url_for('game_info', game=game['title'], lang='en') }}">{{ game['title'] }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>Game Settings Pages</h2>
|
||||
<ul>
|
||||
{% for game in games %}
|
||||
<li><a href="{{ url_for('player_settings', game=game) }}">{{ game }}</a></li>
|
||||
{% for game in games | title_sorted %}
|
||||
{% if game['has_settings'] %}
|
||||
<li><a href="{{ url_for('player_settings', game=game['title']) }}">{{ game['title'] }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -8,79 +8,94 @@
|
||||
|
||||
<body>
|
||||
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||
<table id="inventory-table">
|
||||
<tr>
|
||||
<td><img src="{{ icons['Timespinner Wheel'] }}" class="{{ 'acquired' if 'Timespinner Wheel' in acquired_items }}" title="Timespinner Wheel" /></td>
|
||||
<td><img src="{{ icons['Timespinner Spindle'] }}" class="{{ 'acquired' if 'Timespinner Spindle' in acquired_items }}" title="Timespinner Spindle" /></td>
|
||||
<td><img src="{{ icons['Timespinner Gear 1'] }}" class="{{ 'acquired' if 'Timespinner Gear 1' in acquired_items }}" title="Timespinner Gear 1" /></td>
|
||||
<td><img src="{{ icons['Timespinner Gear 2'] }}" class="{{ 'acquired' if 'Timespinner Gear 2' in acquired_items }}" title="Timespinner Gear 2" /></td>
|
||||
<td><img src="{{ icons['Timespinner Gear 3'] }}" class="{{ 'acquired' if 'Timespinner Gear 3' in acquired_items }}" title="Timespinner Gear 3" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Talaria Attachment'] }}" class="{{ 'acquired' if 'Talaria Attachment' in acquired_items or 'QuickSeed' in options }}" title="Talaria Attachment" /></td>
|
||||
<td><img src="{{ icons['Succubus Hairpin'] }}" class="{{ 'acquired' if 'Succubus Hairpin' in acquired_items }}" title="Succubus Hairpin" /></td>
|
||||
<td><img src="{{ icons['Lightwall'] }}" class="{{ 'acquired' if 'Lightwall' in acquired_items }}" title="Lightwall" /></td>
|
||||
<td><img src="{{ icons['Celestial Sash'] }}" class="{{ 'acquired' if 'Celestial Sash' in acquired_items }}" title="Celestial Sash" /></td>
|
||||
<td><img src="{{ icons['Twin Pyramid Key'] }}" class="{{ 'acquired' if 'Twin Pyramid Key' in acquired_items }}" title="Twin Pyramid Key" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Security Keycard A'] }}" class="{{ 'acquired' if 'Security Keycard A' in acquired_items }}" title="Security Keycard A" /></td>
|
||||
<td><img src="{{ icons['Security Keycard B'] }}" class="{{ 'acquired' if 'Security Keycard B' in acquired_items }}" title="Security Keycard B" /></td>
|
||||
<td><img src="{{ icons['Security Keycard C'] }}" class="{{ 'acquired' if 'Security Keycard C' in acquired_items }}" title="Security Keycard C" /></td>
|
||||
<td><img src="{{ icons['Security Keycard D'] }}" class="{{ 'acquired' if 'Security Keycard D' in acquired_items }}" title="Security Keycard D" /></td>
|
||||
{% if 'DownloadableItems' in options %}
|
||||
<td><img src="{{ icons['Library Keycard V'] }}" class="{{ 'acquired' if 'Library Keycard V' in acquired_items }}" title="Library Keycard V" /></td>
|
||||
{% else %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
<tr>
|
||||
{% if 'DownloadableItems' in options %}
|
||||
<td><img src="{{ icons['Tablet'] }}" class="{{ 'acquired' if 'Tablet' in acquired_items }}" title="Tablet" /></td>
|
||||
{% else %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
|
||||
{% if 'EyeSpy' in options %}
|
||||
<td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td>
|
||||
{% else %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
<td><img src="{{ icons['Water Mask'] }}" class="{{ 'acquired' if 'Water Mask' in acquired_items }}" title="Water Mask" /></td>
|
||||
<td><img src="{{ icons['Gas Mask'] }}" class="{{ 'acquired' if 'Gas Mask' in acquired_items }}" title="Gas Mask" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% if 'GyreArchives' in options %}
|
||||
<td><img src="{{ icons['Kobo'] }}" class="{{ 'acquired' if 'Kobo' in acquired_items }}" title="Kobo" /></td>
|
||||
<td><img src="{{ icons['Merchant Crow'] }}" class="{{ 'acquired' if 'Merchant Crow' in acquired_items }}" title="Merchant Crow" /></td>
|
||||
{% else %}
|
||||
<td></td>
|
||||
<td></td>
|
||||
{% endif %}
|
||||
|
||||
<div id="inventory-table">
|
||||
<div class="table-row">
|
||||
<div class="C1"><img src="{{ icons['Timespinner Wheel'] }}" class="{{ 'acquired' if 'Timespinner Wheel' in acquired_items }}" title="Timespinner Wheel" /></div>
|
||||
<div class="C2"><img src="{{ icons['Timespinner Spindle'] }}" class="{{ 'acquired' if 'Timespinner Spindle' in acquired_items }}" title="Timespinner Spindle" /></div>
|
||||
<div class="C3"><img src="{{ icons['Timespinner Gear 1'] }}" class="{{ 'acquired' if 'Timespinner Gear 1' in acquired_items }}" title="Timespinner Gear 1" /></div>
|
||||
<div class="C4"><img src="{{ icons['Timespinner Gear 2'] }}" class="{{ 'acquired' if 'Timespinner Gear 2' in acquired_items }}" title="Timespinner Gear 2" /></div>
|
||||
<div class="C5"><img src="{{ icons['Timespinner Gear 3'] }}" class="{{ 'acquired' if 'Timespinner Gear 3' in acquired_items }}" title="Timespinner Gear 3" /></div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="C1"><img src="{{ icons['Talaria Attachment'] }}" class="{{ 'acquired' if 'Talaria Attachment' in acquired_items or 'QuickSeed' in options }}" title="Talaria Attachment" /></div>
|
||||
<div class="C2"><img src="{{ icons['Succubus Hairpin'] }}" class="{{ 'acquired' if 'Succubus Hairpin' in acquired_items }}" title="Succubus Hairpin" /></div>
|
||||
<div class="C3"><img src="{{ icons['Lightwall'] }}" class="{{ 'acquired' if 'Lightwall' in acquired_items }}" title="Lightwall" /></div>
|
||||
<div class="C4"><img src="{{ icons['Celestial Sash'] }}" class="{{ 'acquired' if 'Celestial Sash' in acquired_items }}" title="Celestial Sash" /></div>
|
||||
<div class="C5">
|
||||
<div class="image-stack">
|
||||
<div class="stack-back">
|
||||
<img src="{{ icons['Twin Pyramid Key'] }}" class="{{ 'acquired' if 'Twin Pyramid Key' in acquired_items or 'UnchainedKeys' in options }}" title="Twin Pyramid Key" />
|
||||
</div>
|
||||
<div class="stack-front">
|
||||
{% if 'UnchainedKeys' in options %}
|
||||
{% if 'EnterSandman' in options %}
|
||||
<div class="stack-top-right">
|
||||
<img src="{{ icons['Twin Pyramid Key'] }}" class="green {{ 'acquired' if 'Mysterious Warp Beacon' in acquired_items }}" title="Mysterious Warp Beacon" />
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="stack-bottum-left">
|
||||
<img src="{{ icons['Twin Pyramid Key'] }}" class="cyan {{ 'acquired' if 'Timeworn Warp Beacon' in acquired_items }}" title="Timeworn Warp Beacon" />
|
||||
</div>
|
||||
<div class="stack-bottum-right">
|
||||
<img src="{{ icons['Twin Pyramid Key'] }}" class="purple {{ 'acquired' if 'Modern Warp Beacon' in acquired_items }}" title="Modern Warp Beacon" />
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="C1"><img src="{{ icons['Security Keycard A'] }}" class="{{ 'acquired' if 'Security Keycard A' in acquired_items }}" title="Security Keycard A" /></div>
|
||||
<div class="C2"><img src="{{ icons['Security Keycard B'] }}" class="{{ 'acquired' if 'Security Keycard B' in acquired_items }}" title="Security Keycard B" /></div>
|
||||
<div class="C3"><img src="{{ icons['Security Keycard C'] }}" class="{{ 'acquired' if 'Security Keycard C' in acquired_items }}" title="Security Keycard C" /></div>
|
||||
<div class="C4"><img src="{{ icons['Security Keycard D'] }}" class="{{ 'acquired' if 'Security Keycard D' in acquired_items }}" title="Security Keycard D" /></div>
|
||||
{% if 'DownloadableItems' in options %}
|
||||
<div class="C5"><img src="{{ icons['Library Keycard V'] }}" class="{{ 'acquired' if 'Library Keycard V' in acquired_items }}" title="Library Keycard V" /></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="table-row">
|
||||
{% if 'DownloadableItems' in options %}
|
||||
<div class="C1"><img src="{{ icons['Tablet'] }}" class="{{ 'acquired' if 'Tablet' in acquired_items }}" title="Tablet" /></div>
|
||||
{% endif %}
|
||||
<div class="C2"><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></div>
|
||||
{% if 'EyeSpy' in options %}
|
||||
<div class="C3"><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></div>
|
||||
{% endif %}
|
||||
<div class="C4"><img src="{{ icons['Water Mask'] }}" class="{{ 'acquired' if 'Water Mask' in acquired_items }}" title="Water Mask" /></div>
|
||||
<div class="C5"><img src="{{ icons['Gas Mask'] }}" class="{{ 'acquired' if 'Gas Mask' in acquired_items }}" title="Gas Mask" /></div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
{% if 'GyreArchives' in options %}
|
||||
<div class="C1"><img src="{{ icons['Kobo'] }}" class="{{ 'acquired' if 'Kobo' in acquired_items }}" title="Kobo" /></div>
|
||||
<div class="C2"><img src="{{ icons['Merchant Crow'] }}" class="{{ 'acquired' if 'Merchant Crow' in acquired_items }}" title="Merchant Crow" /></div>
|
||||
{% endif %}
|
||||
<div class="C3">
|
||||
{% if 'Djinn Inferno' in acquired_items %}
|
||||
<td><img src="{{ icons['Djinn Inferno'] }}" class="acquired" title="Djinn Inferno" /></td>
|
||||
<img src="{{ icons['Djinn Inferno'] }}" class="acquired" title="Djinn Inferno" />
|
||||
{% elif 'Pyro Ring' in acquired_items %}
|
||||
<td><img src="{{ icons['Pyro Ring'] }}" class="acquired" title="Pyro Ring" /></td>
|
||||
<img src="{{ icons['Pyro Ring'] }}" class="acquired" title="Pyro Ring" />
|
||||
{% elif 'Fire Orb' in acquired_items %}
|
||||
<td><img src="{{ icons['Fire Orb'] }}" class="acquired" title="Fire Orb" /></td>
|
||||
<img src="{{ icons['Fire Orb'] }}" class="acquired" title="Fire Orb" />
|
||||
{% elif 'Infernal Flames' in acquired_items %}
|
||||
<td><img src="{{ icons['Infernal Flames'] }}" class="acquired" title="Infernal Flames" /></td>
|
||||
<img src="{{ icons['Infernal Flames'] }}" class="acquired" title="Infernal Flames" />
|
||||
{% else %}
|
||||
<td><img src="{{ icons['Djinn Inferno'] }}" title="Djinn Inferno" /></td>
|
||||
<img src="{{ icons['Djinn Inferno'] }}" title="Djinn Inferno" />
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<div class="C4">
|
||||
{% if 'Royal Ring' in acquired_items %}
|
||||
<td><img src="{{ icons['Royal Ring'] }}" class="acquired" title="Royal Ring" /></td>
|
||||
<img src="{{ icons['Royal Ring'] }}" class="acquired" title="Royal Ring" />
|
||||
{% elif 'Plasma Geyser' in acquired_items %}
|
||||
<td><img src="{{ icons['Plasma Geyser'] }}" class="acquired" title="Plasma Geyser" /></td>
|
||||
<img src="{{ icons['Plasma Geyser'] }}" class="acquired" title="Plasma Geyser" />
|
||||
{% elif 'Plasma Orb' in acquired_items %}
|
||||
<td><img src="{{ icons['Plasma Orb'] }}" class="acquired" title="Plasma Orb" /></td>
|
||||
<img src="{{ icons['Plasma Orb'] }}" class="acquired" title="Plasma Orb" />
|
||||
{% else %}
|
||||
<td><img src="{{ icons['Royal Ring'] }}" title="Royal Ring" /></td>
|
||||
<img src="{{ icons['Royal Ring'] }}" title="Royal Ring" />
|
||||
{% endif %}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table id="location-table">
|
||||
{% for area in checks_done %}
|
||||
<tr class="location-category" id="{{area}}-header">
|
||||
|
||||
@@ -5,9 +5,10 @@ from typing import Counter, Optional, Dict, Any, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from flask import render_template
|
||||
from jinja2 import pass_context, runtime
|
||||
from werkzeug.exceptions import abort
|
||||
|
||||
from MultiServer import Context
|
||||
from MultiServer import Context, get_saving_second
|
||||
from NetUtils import SlotType
|
||||
from Utils import restricted_loads
|
||||
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
||||
@@ -83,9 +84,6 @@ def get_alttp_id(item_name):
|
||||
return Items.item_table[item_name][2]
|
||||
|
||||
|
||||
app.jinja_env.filters["location_name"] = lambda location: lookup_any_location_id_to_name.get(location, location)
|
||||
app.jinja_env.filters['item_name'] = lambda id: lookup_any_item_id_to_name.get(id, id)
|
||||
|
||||
links = {"Bow": "Progressive Bow",
|
||||
"Silver Arrows": "Progressive Bow",
|
||||
"Silver Bow": "Progressive Bow",
|
||||
@@ -212,14 +210,6 @@ del data
|
||||
del item
|
||||
|
||||
|
||||
def attribute_item(inventory, team, recipient, item):
|
||||
target_item = links.get(item, item)
|
||||
if item in levels: # non-progressive
|
||||
inventory[team][recipient][target_item] = max(inventory[team][recipient][target_item], levels[item])
|
||||
else:
|
||||
inventory[team][recipient][target_item] += 1
|
||||
|
||||
|
||||
def attribute_item_solo(inventory, item):
|
||||
"""Adds item to inventory counter, converts everything to progressive."""
|
||||
target_item = links.get(item, item)
|
||||
@@ -237,6 +227,22 @@ def render_timedelta(delta: datetime.timedelta):
|
||||
return f"{hours}:{minutes}"
|
||||
|
||||
|
||||
@pass_context
|
||||
def get_location_name(context: runtime.Context, loc: int) -> str:
|
||||
context_locations = context.get("custom_locations", {})
|
||||
return collections.ChainMap(lookup_any_location_id_to_name, context_locations).get(loc, loc)
|
||||
|
||||
|
||||
@pass_context
|
||||
def get_item_name(context: runtime.Context, item: int) -> str:
|
||||
context_items = context.get("custom_items", {})
|
||||
return collections.ChainMap(lookup_any_item_id_to_name, context_items).get(item, item)
|
||||
|
||||
|
||||
app.jinja_env.filters["location_name"] = get_location_name
|
||||
app.jinja_env.filters["item_name"] = get_item_name
|
||||
|
||||
|
||||
_multidata_cache = {}
|
||||
|
||||
|
||||
@@ -258,10 +264,23 @@ def get_static_room_data(room: Room):
|
||||
# in > 100 players this can take a bit of time and is the main reason for the cache
|
||||
locations: Dict[int, Dict[int, Tuple[int, int, int]]] = multidata['locations']
|
||||
names: Dict[int, Dict[int, str]] = multidata["names"]
|
||||
games = {}
|
||||
groups = {}
|
||||
custom_locations = {}
|
||||
custom_items = {}
|
||||
if "slot_info" in multidata:
|
||||
games = {slot: slot_info.game for slot, slot_info in multidata["slot_info"].items()}
|
||||
groups = {slot: slot_info.group_members for slot, slot_info in multidata["slot_info"].items()
|
||||
if slot_info.type == SlotType.group}
|
||||
|
||||
for game in games.values():
|
||||
if game in multidata["datapackage"]:
|
||||
custom_locations.update(
|
||||
{id: name for name, id in multidata["datapackage"][game]["location_name_to_id"].items()})
|
||||
custom_items.update(
|
||||
{id: name for name, id in multidata["datapackage"][game]["item_name_to_id"].items()})
|
||||
elif "games" in multidata:
|
||||
games = multidata["games"]
|
||||
seed_checks_in_area = checks_in_area.copy()
|
||||
|
||||
use_door_tracker = False
|
||||
@@ -280,16 +299,26 @@ def get_static_room_data(room: Room):
|
||||
player_location_to_area = {playernumber: get_location_table(multidata["checks_in_area"][playernumber])
|
||||
for playernumber in range(1, len(names[0]) + 1)
|
||||
if playernumber not in groups}
|
||||
|
||||
saving_second = get_saving_second(multidata["seed_name"])
|
||||
result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, \
|
||||
multidata["precollected_items"], multidata["games"], multidata["slot_data"], groups
|
||||
multidata["precollected_items"], games, multidata["slot_data"], groups, saving_second, \
|
||||
custom_locations, custom_items
|
||||
_multidata_cache[room.seed.id] = result
|
||||
return result
|
||||
|
||||
|
||||
@app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
|
||||
@cache.memoize(timeout=60) # multisave is currently created at most every minute
|
||||
def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False):
|
||||
def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False):
|
||||
key = f"{tracker}_{tracked_team}_{tracked_player}_{want_generic}"
|
||||
tracker_page = cache.get(key)
|
||||
if tracker_page:
|
||||
return tracker_page
|
||||
timeout, tracker_page = _get_player_tracker(tracker, tracked_team, tracked_player, want_generic)
|
||||
cache.set(key, tracker_page, timeout)
|
||||
return tracker_page
|
||||
|
||||
|
||||
def _get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool):
|
||||
# Team and player must be positive and greater than zero
|
||||
if tracked_team < 0 or tracked_player < 1:
|
||||
abort(404)
|
||||
@@ -300,7 +329,8 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
|
||||
|
||||
# Collect seed information and pare it down to a single player
|
||||
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
|
||||
precollected_items, games, slot_data, groups = get_static_room_data(room)
|
||||
precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \
|
||||
get_static_room_data(room)
|
||||
player_name = names[tracked_team][tracked_player - 1]
|
||||
location_to_area = player_location_to_area[tracked_player]
|
||||
inventory = collections.Counter()
|
||||
@@ -338,21 +368,24 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
|
||||
checks_done["Total"] += 1
|
||||
specific_tracker = game_specific_trackers.get(games[tracked_player], None)
|
||||
if specific_tracker and not want_generic:
|
||||
return specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
|
||||
seed_checks_in_area, checks_done, slot_data[tracked_player])
|
||||
tracker = specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
|
||||
seed_checks_in_area, checks_done, slot_data[tracked_player], saving_second)
|
||||
else:
|
||||
return __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
|
||||
seed_checks_in_area, checks_done)
|
||||
tracker = __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
|
||||
seed_checks_in_area, checks_done, saving_second, custom_locations, custom_items)
|
||||
|
||||
return (saving_second - datetime.datetime.now().second) % 60 or 60, tracker
|
||||
|
||||
|
||||
@app.route('/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
|
||||
def get_generic_tracker(tracker: UUID, tracked_team: int, tracked_player: int):
|
||||
return getPlayerTracker(tracker, tracked_team, tracked_player, True)
|
||||
return get_player_tracker(tracker, tracked_team, tracked_player, True)
|
||||
|
||||
|
||||
def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, player_name: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict,
|
||||
saving_second: int) -> str:
|
||||
|
||||
# Note the presence of the triforce item
|
||||
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
|
||||
@@ -414,7 +447,8 @@ def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[
|
||||
|
||||
def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, playerName: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict,
|
||||
saving_second: int) -> str:
|
||||
|
||||
icons = {
|
||||
"Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png",
|
||||
@@ -444,7 +478,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
|
||||
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
|
||||
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
|
||||
"Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png",
|
||||
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
|
||||
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
|
||||
"Saddle": "https://i.imgur.com/2QtDyR0.png",
|
||||
"Channeling Book": "https://i.imgur.com/J3WsYZw.png",
|
||||
"Silk Touch Book": "https://i.imgur.com/iqERxHQ.png",
|
||||
@@ -452,7 +486,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
|
||||
}
|
||||
|
||||
minecraft_location_ids = {
|
||||
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
|
||||
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
|
||||
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
|
||||
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
|
||||
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014],
|
||||
@@ -516,14 +550,15 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
|
||||
inventory=inventory, icons=icons,
|
||||
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
|
||||
id in lookup_any_item_id_to_name},
|
||||
player=player, team=team, room=room, player_name=playerName,
|
||||
player=player, team=team, room=room, player_name=playerName, saving_second = saving_second,
|
||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
||||
**display_data)
|
||||
|
||||
|
||||
def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, playerName: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict,
|
||||
saving_second: int) -> str:
|
||||
|
||||
icons = {
|
||||
"Fairy Ocarina": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/OoT_Fairy_Ocarina_Icon.png",
|
||||
@@ -613,7 +648,7 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
|
||||
|
||||
if base_name == "hookshot":
|
||||
display_data['hookshot_length'] = {0: '', 1: 'H', 2: 'L'}.get(level)
|
||||
if base_name == "wallet":
|
||||
if base_name == "wallet":
|
||||
display_data['wallet_size'] = {0: '99', 1: '200', 2: '500', 3: '999'}.get(level)
|
||||
|
||||
# Determine display for bottles. Show letter if it's obtained, determine bottle count
|
||||
@@ -631,48 +666,51 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
|
||||
}
|
||||
for item_name, item_id in multi_items.items():
|
||||
base_name = item_name.split()[-1].lower()
|
||||
count = inventory[item_id]
|
||||
display_data[base_name+"_count"] = inventory[item_id]
|
||||
|
||||
# Gather dungeon locations
|
||||
area_id_ranges = {
|
||||
"Overworld": (67000, 67280),
|
||||
"Deku Tree": (67281, 67303),
|
||||
"Dodongo's Cavern": (67304, 67334),
|
||||
"Jabu Jabu's Belly": (67335, 67359),
|
||||
"Bottom of the Well": (67360, 67384),
|
||||
"Forest Temple": (67385, 67420),
|
||||
"Fire Temple": (67421, 67457),
|
||||
"Water Temple": (67458, 67484),
|
||||
"Shadow Temple": (67485, 67532),
|
||||
"Spirit Temple": (67533, 67582),
|
||||
"Ice Cavern": (67583, 67596),
|
||||
"Gerudo Training Ground": (67597, 67635),
|
||||
"Thieves' Hideout": (67259, 67263),
|
||||
"Ganon's Castle": (67636, 67673),
|
||||
"Overworld": ((67000, 67263), (67269, 67280), (67747, 68024), (68054, 68062)),
|
||||
"Deku Tree": ((67281, 67303), (68063, 68077)),
|
||||
"Dodongo's Cavern": ((67304, 67334), (68078, 68160)),
|
||||
"Jabu Jabu's Belly": ((67335, 67359), (68161, 68188)),
|
||||
"Bottom of the Well": ((67360, 67384), (68189, 68230)),
|
||||
"Forest Temple": ((67385, 67420), (68231, 68281)),
|
||||
"Fire Temple": ((67421, 67457), (68282, 68350)),
|
||||
"Water Temple": ((67458, 67484), (68351, 68483)),
|
||||
"Shadow Temple": ((67485, 67532), (68484, 68565)),
|
||||
"Spirit Temple": ((67533, 67582), (68566, 68625)),
|
||||
"Ice Cavern": ((67583, 67596), (68626, 68649)),
|
||||
"Gerudo Training Ground": ((67597, 67635), (68650, 68656)),
|
||||
"Thieves' Hideout": ((67264, 67268), (68025, 68053)),
|
||||
"Ganon's Castle": ((67636, 67673), (68657, 68705)),
|
||||
}
|
||||
|
||||
def lookup_and_trim(id, area):
|
||||
full_name = lookup_any_location_id_to_name[id]
|
||||
if id == 67673:
|
||||
return full_name[13:] # Ganons Tower Boss Key Chest
|
||||
if 'Ganons Tower' in full_name:
|
||||
return full_name
|
||||
if area not in ["Overworld", "Thieves' Hideout"]:
|
||||
# trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC
|
||||
return full_name[len(area):]
|
||||
return full_name
|
||||
|
||||
checked_locations = multisave.get("location_checks", {}).get((team, player), set()).intersection(set(locations[player]))
|
||||
location_info = {area: {lookup_and_trim(id, area): id in checked_locations for id in range(min_id, max_id+1) if id in locations[player]}
|
||||
for area, (min_id, max_id) in area_id_ranges.items()}
|
||||
checks_done = {area: len(list(filter(lambda x: x, location_info[area].values()))) for area in area_id_ranges}
|
||||
checks_in_area = {area: len([id for id in range(min_id, max_id+1) if id in locations[player]])
|
||||
for area, (min_id, max_id) in area_id_ranges.items()}
|
||||
|
||||
# Remove Thieves' Hideout checks from Overworld, since it's in the middle of the range
|
||||
checks_in_area["Overworld"] -= checks_in_area["Thieves' Hideout"]
|
||||
checks_done["Overworld"] -= checks_done["Thieves' Hideout"]
|
||||
for loc in location_info["Thieves' Hideout"]:
|
||||
del location_info["Overworld"][loc]
|
||||
location_info = {}
|
||||
checks_done = {}
|
||||
checks_in_area = {}
|
||||
for area, ranges in area_id_ranges.items():
|
||||
location_info[area] = {}
|
||||
checks_done[area] = 0
|
||||
checks_in_area[area] = 0
|
||||
for r in ranges:
|
||||
min_id, max_id = r
|
||||
for id in range(min_id, max_id+1):
|
||||
if id in locations[player]:
|
||||
checked = id in checked_locations
|
||||
location_info[area][lookup_and_trim(id, area)] = checked
|
||||
checks_in_area[area] += 1
|
||||
checks_done[area] += checked
|
||||
|
||||
checks_done['Total'] = sum(checks_done.values())
|
||||
checks_in_area['Total'] = sum(checks_in_area.values())
|
||||
@@ -683,25 +721,28 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
|
||||
if "GS" in lookup_and_trim(id, ''):
|
||||
display_data["token_count"] += 1
|
||||
|
||||
oot_y = '✔'
|
||||
oot_x = '✕'
|
||||
|
||||
# Gather small and boss key info
|
||||
small_key_counts = {
|
||||
"Forest Temple": inventory[66175],
|
||||
"Fire Temple": inventory[66176],
|
||||
"Water Temple": inventory[66177],
|
||||
"Spirit Temple": inventory[66178],
|
||||
"Shadow Temple": inventory[66179],
|
||||
"Bottom of the Well": inventory[66180],
|
||||
"Gerudo Training Ground": inventory[66181],
|
||||
"Thieves' Hideout": inventory[66182],
|
||||
"Ganon's Castle": inventory[66183],
|
||||
"Forest Temple": oot_y if inventory[66203] else inventory[66175],
|
||||
"Fire Temple": oot_y if inventory[66204] else inventory[66176],
|
||||
"Water Temple": oot_y if inventory[66205] else inventory[66177],
|
||||
"Spirit Temple": oot_y if inventory[66206] else inventory[66178],
|
||||
"Shadow Temple": oot_y if inventory[66207] else inventory[66179],
|
||||
"Bottom of the Well": oot_y if inventory[66208] else inventory[66180],
|
||||
"Gerudo Training Ground": oot_y if inventory[66209] else inventory[66181],
|
||||
"Thieves' Hideout": oot_y if inventory[66210] else inventory[66182],
|
||||
"Ganon's Castle": oot_y if inventory[66211] else inventory[66183],
|
||||
}
|
||||
boss_key_counts = {
|
||||
"Forest Temple": '✔' if inventory[66149] else '✕',
|
||||
"Fire Temple": '✔' if inventory[66150] else '✕',
|
||||
"Water Temple": '✔' if inventory[66151] else '✕',
|
||||
"Spirit Temple": '✔' if inventory[66152] else '✕',
|
||||
"Shadow Temple": '✔' if inventory[66153] else '✕',
|
||||
"Ganon's Castle": '✔' if inventory[66154] else '✕',
|
||||
"Forest Temple": oot_y if inventory[66149] else oot_x,
|
||||
"Fire Temple": oot_y if inventory[66150] else oot_x,
|
||||
"Water Temple": oot_y if inventory[66151] else oot_x,
|
||||
"Spirit Temple": oot_y if inventory[66152] else oot_x,
|
||||
"Shadow Temple": oot_y if inventory[66153] else oot_x,
|
||||
"Ganon's Castle": oot_y if inventory[66154] else oot_x,
|
||||
}
|
||||
|
||||
# Victory condition
|
||||
@@ -718,7 +759,8 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
|
||||
|
||||
def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, playerName: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict[str, Any]) -> str:
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int],
|
||||
slot_data: Dict[str, Any], saving_second: int) -> str:
|
||||
|
||||
icons = {
|
||||
"Timespinner Wheel": "https://timespinnerwiki.com/mediawiki/images/7/76/Timespinner_Wheel.png",
|
||||
@@ -753,7 +795,7 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
|
||||
}
|
||||
|
||||
timespinner_location_ids = {
|
||||
"Present": [
|
||||
"Present": [
|
||||
1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009,
|
||||
1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019,
|
||||
1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029,
|
||||
@@ -774,20 +816,20 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
|
||||
1337150, 1337151, 1337152, 1337153, 1337154, 1337155,
|
||||
1337171, 1337172, 1337173, 1337174, 1337175],
|
||||
"Ancient Pyramid": [
|
||||
1337236,
|
||||
1337236,
|
||||
1337246, 1337247, 1337248, 1337249]
|
||||
}
|
||||
|
||||
if(slot_data["DownloadableItems"]):
|
||||
timespinner_location_ids["Present"] += [
|
||||
1337156, 1337157, 1337159,
|
||||
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
|
||||
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
|
||||
1337170]
|
||||
if(slot_data["Cantoran"]):
|
||||
timespinner_location_ids["Past"].append(1337176)
|
||||
if(slot_data["LoreChecks"]):
|
||||
timespinner_location_ids["Present"] += [
|
||||
1337177, 1337178, 1337179,
|
||||
1337177, 1337178, 1337179,
|
||||
1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187]
|
||||
timespinner_location_ids["Past"] += [
|
||||
1337188, 1337189,
|
||||
@@ -824,7 +866,8 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
|
||||
|
||||
def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, playerName: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict,
|
||||
saving_second: int) -> str:
|
||||
|
||||
icons = {
|
||||
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ETank.png",
|
||||
@@ -922,45 +965,390 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
|
||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
||||
**display_data)
|
||||
|
||||
def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, playerName: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int],
|
||||
slot_data: Dict, saving_second: int) -> str:
|
||||
|
||||
SC2WOL_LOC_ID_OFFSET = 1000
|
||||
SC2WOL_ITEM_ID_OFFSET = 1000
|
||||
|
||||
icons = {
|
||||
"Starting Minerals": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-mineral-protoss.png",
|
||||
"Starting Vespene": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-gas-terran.png",
|
||||
"Starting Supply": "https://static.wikia.nocookie.net/starcraft/images/d/d3/TerranSupply_SC2_Icon1.gif",
|
||||
|
||||
"Infantry Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel1.png",
|
||||
"Infantry Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel2.png",
|
||||
"Infantry Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel3.png",
|
||||
"Infantry Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel1.png",
|
||||
"Infantry Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel2.png",
|
||||
"Infantry Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel3.png",
|
||||
"Vehicle Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel1.png",
|
||||
"Vehicle Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel2.png",
|
||||
"Vehicle Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel3.png",
|
||||
"Vehicle Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel1.png",
|
||||
"Vehicle Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel2.png",
|
||||
"Vehicle Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel3.png",
|
||||
"Ship Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel1.png",
|
||||
"Ship Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel2.png",
|
||||
"Ship Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel3.png",
|
||||
"Ship Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel1.png",
|
||||
"Ship Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel2.png",
|
||||
"Ship Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel3.png",
|
||||
|
||||
"Bunker": "https://static.wikia.nocookie.net/starcraft/images/c/c5/Bunker_SC2_Icon1.jpg",
|
||||
"Missile Turret": "https://static.wikia.nocookie.net/starcraft/images/5/5f/MissileTurret_SC2_Icon1.jpg",
|
||||
"Sensor Tower": "https://static.wikia.nocookie.net/starcraft/images/d/d2/SensorTower_SC2_Icon1.jpg",
|
||||
|
||||
"Projectile Accelerator (Bunker)": "https://0rganics.org/archipelago/sc2wol/ProjectileAccelerator.png",
|
||||
"Neosteel Bunker (Bunker)": "https://0rganics.org/archipelago/sc2wol/NeosteelBunker.png",
|
||||
"Titanium Housing (Missile Turret)": "https://0rganics.org/archipelago/sc2wol/TitaniumHousing.png",
|
||||
"Hellstorm Batteries (Missile Turret)": "https://0rganics.org/archipelago/sc2wol/HellstormBatteries.png",
|
||||
"Advanced Construction (SCV)": "https://0rganics.org/archipelago/sc2wol/AdvancedConstruction.png",
|
||||
"Dual-Fusion Welders (SCV)": "https://0rganics.org/archipelago/sc2wol/Dual-FusionWelders.png",
|
||||
"Fire-Suppression System (Building)": "https://0rganics.org/archipelago/sc2wol/Fire-SuppressionSystem.png",
|
||||
"Orbital Command (Building)": "https://0rganics.org/archipelago/sc2wol/OrbitalCommandCampaign.png",
|
||||
|
||||
"Marine": "https://static.wikia.nocookie.net/starcraft/images/4/47/Marine_SC2_Icon1.jpg",
|
||||
"Medic": "https://static.wikia.nocookie.net/starcraft/images/7/74/Medic_SC2_Rend1.jpg",
|
||||
"Firebat": "https://static.wikia.nocookie.net/starcraft/images/3/3c/Firebat_SC2_Rend1.jpg",
|
||||
"Marauder": "https://static.wikia.nocookie.net/starcraft/images/b/ba/Marauder_SC2_Icon1.jpg",
|
||||
"Reaper": "https://static.wikia.nocookie.net/starcraft/images/7/7d/Reaper_SC2_Icon1.jpg",
|
||||
|
||||
"Stimpack (Marine)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png",
|
||||
"Combat Shield (Marine)": "https://0rganics.org/archipelago/sc2wol/CombatShieldCampaign.png",
|
||||
"Advanced Medic Facilities (Medic)": "https://0rganics.org/archipelago/sc2wol/AdvancedMedicFacilities.png",
|
||||
"Stabilizer Medpacks (Medic)": "https://0rganics.org/archipelago/sc2wol/StabilizerMedpacks.png",
|
||||
"Incinerator Gauntlets (Firebat)": "https://0rganics.org/archipelago/sc2wol/IncineratorGauntlets.png",
|
||||
"Juggernaut Plating (Firebat)": "https://0rganics.org/archipelago/sc2wol/JuggernautPlating.png",
|
||||
"Concussive Shells (Marauder)": "https://0rganics.org/archipelago/sc2wol/ConcussiveShellsCampaign.png",
|
||||
"Kinetic Foam (Marauder)": "https://0rganics.org/archipelago/sc2wol/KineticFoam.png",
|
||||
"U-238 Rounds (Reaper)": "https://0rganics.org/archipelago/sc2wol/U-238Rounds.png",
|
||||
"G-4 Clusterbomb (Reaper)": "https://0rganics.org/archipelago/sc2wol/G-4Clusterbomb.png",
|
||||
|
||||
"Hellion": "https://static.wikia.nocookie.net/starcraft/images/5/56/Hellion_SC2_Icon1.jpg",
|
||||
"Vulture": "https://static.wikia.nocookie.net/starcraft/images/d/da/Vulture_WoL.jpg",
|
||||
"Goliath": "https://static.wikia.nocookie.net/starcraft/images/e/eb/Goliath_WoL.jpg",
|
||||
"Diamondback": "https://static.wikia.nocookie.net/starcraft/images/a/a6/Diamondback_WoL.jpg",
|
||||
"Siege Tank": "https://static.wikia.nocookie.net/starcraft/images/5/57/SiegeTank_SC2_Icon1.jpg",
|
||||
|
||||
"Twin-Linked Flamethrower (Hellion)": "https://0rganics.org/archipelago/sc2wol/Twin-LinkedFlamethrower.png",
|
||||
"Thermite Filaments (Hellion)": "https://0rganics.org/archipelago/sc2wol/ThermiteFilaments.png",
|
||||
"Cerberus Mine (Vulture)": "https://0rganics.org/archipelago/sc2wol/CerberusMine.png",
|
||||
"Replenishable Magazine (Vulture)": "https://0rganics.org/archipelago/sc2wol/ReplenishableMagazine.png",
|
||||
"Multi-Lock Weapons System (Goliath)": "https://0rganics.org/archipelago/sc2wol/Multi-LockWeaponsSystem.png",
|
||||
"Ares-Class Targeting System (Goliath)": "https://0rganics.org/archipelago/sc2wol/Ares-ClassTargetingSystem.png",
|
||||
"Tri-Lithium Power Cell (Diamondback)": "https://0rganics.org/archipelago/sc2wol/Tri-LithiumPowerCell.png",
|
||||
"Shaped Hull (Diamondback)": "https://0rganics.org/archipelago/sc2wol/ShapedHull.png",
|
||||
"Maelstrom Rounds (Siege Tank)": "https://0rganics.org/archipelago/sc2wol/MaelstromRounds.png",
|
||||
"Shaped Blast (Siege Tank)": "https://0rganics.org/archipelago/sc2wol/ShapedBlast.png",
|
||||
|
||||
"Medivac": "https://static.wikia.nocookie.net/starcraft/images/d/db/Medivac_SC2_Icon1.jpg",
|
||||
"Wraith": "https://static.wikia.nocookie.net/starcraft/images/7/75/Wraith_WoL.jpg",
|
||||
"Viking": "https://static.wikia.nocookie.net/starcraft/images/2/2a/Viking_SC2_Icon1.jpg",
|
||||
"Banshee": "https://static.wikia.nocookie.net/starcraft/images/3/32/Banshee_SC2_Icon1.jpg",
|
||||
"Battlecruiser": "https://static.wikia.nocookie.net/starcraft/images/f/f5/Battlecruiser_SC2_Icon1.jpg",
|
||||
|
||||
"Rapid Deployment Tube (Medivac)": "https://0rganics.org/archipelago/sc2wol/RapidDeploymentTube.png",
|
||||
"Advanced Healing AI (Medivac)": "https://0rganics.org/archipelago/sc2wol/AdvancedHealingAI.png",
|
||||
"Tomahawk Power Cells (Wraith)": "https://0rganics.org/archipelago/sc2wol/TomahawkPowerCells.png",
|
||||
"Displacement Field (Wraith)": "https://0rganics.org/archipelago/sc2wol/DisplacementField.png",
|
||||
"Ripwave Missiles (Viking)": "https://0rganics.org/archipelago/sc2wol/RipwaveMissiles.png",
|
||||
"Phobos-Class Weapons System (Viking)": "https://0rganics.org/archipelago/sc2wol/Phobos-ClassWeaponsSystem.png",
|
||||
"Cross-Spectrum Dampeners (Banshee)": "https://0rganics.org/archipelago/sc2wol/Cross-SpectrumDampeners.png",
|
||||
"Shockwave Missile Battery (Banshee)": "https://0rganics.org/archipelago/sc2wol/ShockwaveMissileBattery.png",
|
||||
"Missile Pods (Battlecruiser)": "https://0rganics.org/archipelago/sc2wol/MissilePods.png",
|
||||
"Defensive Matrix (Battlecruiser)": "https://0rganics.org/archipelago/sc2wol/DefensiveMatrix.png",
|
||||
|
||||
"Ghost": "https://static.wikia.nocookie.net/starcraft/images/6/6e/Ghost_SC2_Icon1.jpg",
|
||||
"Spectre": "https://static.wikia.nocookie.net/starcraft/images/0/0d/Spectre_WoL.jpg",
|
||||
"Thor": "https://static.wikia.nocookie.net/starcraft/images/e/ef/Thor_SC2_Icon1.jpg",
|
||||
|
||||
"Ocular Implants (Ghost)": "https://0rganics.org/archipelago/sc2wol/OcularImplants.png",
|
||||
"Crius Suit (Ghost)": "https://0rganics.org/archipelago/sc2wol/CriusSuit.png",
|
||||
"Psionic Lash (Spectre)": "https://0rganics.org/archipelago/sc2wol/PsionicLash.png",
|
||||
"Nyx-Class Cloaking Module (Spectre)": "https://0rganics.org/archipelago/sc2wol/Nyx-ClassCloakingModule.png",
|
||||
"330mm Barrage Cannon (Thor)": "https://0rganics.org/archipelago/sc2wol/330mmBarrageCannon.png",
|
||||
"Immortality Protocol (Thor)": "https://0rganics.org/archipelago/sc2wol/ImmortalityProtocol.png",
|
||||
|
||||
"War Pigs": "https://static.wikia.nocookie.net/starcraft/images/e/ed/WarPigs_SC2_Icon1.jpg",
|
||||
"Devil Dogs": "https://static.wikia.nocookie.net/starcraft/images/3/33/DevilDogs_SC2_Icon1.jpg",
|
||||
"Hammer Securities": "https://static.wikia.nocookie.net/starcraft/images/3/3b/HammerSecurity_SC2_Icon1.jpg",
|
||||
"Spartan Company": "https://static.wikia.nocookie.net/starcraft/images/b/be/SpartanCompany_SC2_Icon1.jpg",
|
||||
"Siege Breakers": "https://static.wikia.nocookie.net/starcraft/images/3/31/SiegeBreakers_SC2_Icon1.jpg",
|
||||
"Hel's Angel": "https://static.wikia.nocookie.net/starcraft/images/6/63/HelsAngels_SC2_Icon1.jpg",
|
||||
"Dusk Wings": "https://static.wikia.nocookie.net/starcraft/images/5/52/DuskWings_SC2_Icon1.jpg",
|
||||
"Jackson's Revenge": "https://static.wikia.nocookie.net/starcraft/images/9/95/JacksonsRevenge_SC2_Icon1.jpg",
|
||||
|
||||
"Ultra-Capacitors": "https://static.wikia.nocookie.net/starcraft/images/2/23/SC2_Lab_Ultra_Capacitors_Icon.png",
|
||||
"Vanadium Plating": "https://static.wikia.nocookie.net/starcraft/images/6/67/SC2_Lab_VanPlating_Icon.png",
|
||||
"Orbital Depots": "https://static.wikia.nocookie.net/starcraft/images/0/01/SC2_Lab_Orbital_Depot_Icon.png",
|
||||
"Micro-Filtering": "https://static.wikia.nocookie.net/starcraft/images/2/20/SC2_Lab_MicroFilter_Icon.png",
|
||||
"Automated Refinery": "https://static.wikia.nocookie.net/starcraft/images/7/71/SC2_Lab_Auto_Refinery_Icon.png",
|
||||
"Command Center Reactor": "https://static.wikia.nocookie.net/starcraft/images/e/ef/SC2_Lab_CC_Reactor_Icon.png",
|
||||
"Raven": "https://static.wikia.nocookie.net/starcraft/images/1/19/SC2_Lab_Raven_Icon.png",
|
||||
"Science Vessel": "https://static.wikia.nocookie.net/starcraft/images/c/c3/SC2_Lab_SciVes_Icon.png",
|
||||
"Tech Reactor": "https://static.wikia.nocookie.net/starcraft/images/c/c5/SC2_Lab_Tech_Reactor_Icon.png",
|
||||
"Orbital Strike": "https://static.wikia.nocookie.net/starcraft/images/d/df/SC2_Lab_Orb_Strike_Icon.png",
|
||||
|
||||
"Shrike Turret": "https://static.wikia.nocookie.net/starcraft/images/4/44/SC2_Lab_Shrike_Turret_Icon.png",
|
||||
"Fortified Bunker": "https://static.wikia.nocookie.net/starcraft/images/4/4f/SC2_Lab_FortBunker_Icon.png",
|
||||
"Planetary Fortress": "https://static.wikia.nocookie.net/starcraft/images/0/0b/SC2_Lab_PlanetFortress_Icon.png",
|
||||
"Perdition Turret": "https://static.wikia.nocookie.net/starcraft/images/a/af/SC2_Lab_PerdTurret_Icon.png",
|
||||
"Predator": "https://static.wikia.nocookie.net/starcraft/images/8/83/SC2_Lab_Predator_Icon.png",
|
||||
"Hercules": "https://static.wikia.nocookie.net/starcraft/images/4/40/SC2_Lab_Hercules_Icon.png",
|
||||
"Cellular Reactor": "https://static.wikia.nocookie.net/starcraft/images/d/d8/SC2_Lab_CellReactor_Icon.png",
|
||||
"Regenerative Bio-Steel": "https://static.wikia.nocookie.net/starcraft/images/d/d3/SC2_Lab_BioSteel_Icon.png",
|
||||
"Hive Mind Emulator": "https://static.wikia.nocookie.net/starcraft/images/b/bc/SC2_Lab_Hive_Emulator_Icon.png",
|
||||
"Psi Disrupter": "https://static.wikia.nocookie.net/starcraft/images/c/cf/SC2_Lab_Psi_Disruptor_Icon.png",
|
||||
|
||||
"Zealot": "https://static.wikia.nocookie.net/starcraft/images/6/6e/Icon_Protoss_Zealot.jpg",
|
||||
"Stalker": "https://static.wikia.nocookie.net/starcraft/images/0/0d/Icon_Protoss_Stalker.jpg",
|
||||
"High Templar": "https://static.wikia.nocookie.net/starcraft/images/a/a0/Icon_Protoss_High_Templar.jpg",
|
||||
"Dark Templar": "https://static.wikia.nocookie.net/starcraft/images/9/90/Icon_Protoss_Dark_Templar.jpg",
|
||||
"Immortal": "https://static.wikia.nocookie.net/starcraft/images/c/c1/Icon_Protoss_Immortal.jpg",
|
||||
"Colossus": "https://static.wikia.nocookie.net/starcraft/images/4/40/Icon_Protoss_Colossus.jpg",
|
||||
"Phoenix": "https://static.wikia.nocookie.net/starcraft/images/b/b1/Icon_Protoss_Phoenix.jpg",
|
||||
"Void Ray": "https://static.wikia.nocookie.net/starcraft/images/1/1d/VoidRay_SC2_Rend1.jpg",
|
||||
"Carrier": "https://static.wikia.nocookie.net/starcraft/images/2/2c/Icon_Protoss_Carrier.jpg",
|
||||
|
||||
"Nothing": "",
|
||||
}
|
||||
|
||||
sc2wol_location_ids = {
|
||||
"Liberation Day": [SC2WOL_LOC_ID_OFFSET + 100, SC2WOL_LOC_ID_OFFSET + 101, SC2WOL_LOC_ID_OFFSET + 102, SC2WOL_LOC_ID_OFFSET + 103, SC2WOL_LOC_ID_OFFSET + 104, SC2WOL_LOC_ID_OFFSET + 105, SC2WOL_LOC_ID_OFFSET + 106],
|
||||
"The Outlaws": [SC2WOL_LOC_ID_OFFSET + 200, SC2WOL_LOC_ID_OFFSET + 201],
|
||||
"Zero Hour": [SC2WOL_LOC_ID_OFFSET + 300, SC2WOL_LOC_ID_OFFSET + 301, SC2WOL_LOC_ID_OFFSET + 302, SC2WOL_LOC_ID_OFFSET + 303],
|
||||
"Evacuation": [SC2WOL_LOC_ID_OFFSET + 400, SC2WOL_LOC_ID_OFFSET + 401, SC2WOL_LOC_ID_OFFSET + 402, SC2WOL_LOC_ID_OFFSET + 403],
|
||||
"Outbreak": [SC2WOL_LOC_ID_OFFSET + 500, SC2WOL_LOC_ID_OFFSET + 501, SC2WOL_LOC_ID_OFFSET + 502],
|
||||
"Safe Haven": [SC2WOL_LOC_ID_OFFSET + 600, SC2WOL_LOC_ID_OFFSET + 601, SC2WOL_LOC_ID_OFFSET + 602, SC2WOL_LOC_ID_OFFSET + 603],
|
||||
"Haven's Fall": [SC2WOL_LOC_ID_OFFSET + 700, SC2WOL_LOC_ID_OFFSET + 701, SC2WOL_LOC_ID_OFFSET + 702, SC2WOL_LOC_ID_OFFSET + 703],
|
||||
"Smash and Grab": [SC2WOL_LOC_ID_OFFSET + 800, SC2WOL_LOC_ID_OFFSET + 801, SC2WOL_LOC_ID_OFFSET + 802, SC2WOL_LOC_ID_OFFSET + 803, SC2WOL_LOC_ID_OFFSET + 804],
|
||||
"The Dig": [SC2WOL_LOC_ID_OFFSET + 900, SC2WOL_LOC_ID_OFFSET + 901, SC2WOL_LOC_ID_OFFSET + 902, SC2WOL_LOC_ID_OFFSET + 903],
|
||||
"The Moebius Factor": [SC2WOL_LOC_ID_OFFSET + 1000, SC2WOL_LOC_ID_OFFSET + 1003, SC2WOL_LOC_ID_OFFSET + 1004, SC2WOL_LOC_ID_OFFSET + 1005, SC2WOL_LOC_ID_OFFSET + 1006, SC2WOL_LOC_ID_OFFSET + 1007, SC2WOL_LOC_ID_OFFSET + 1008],
|
||||
"Supernova": [SC2WOL_LOC_ID_OFFSET + 1100, SC2WOL_LOC_ID_OFFSET + 1101, SC2WOL_LOC_ID_OFFSET + 1102, SC2WOL_LOC_ID_OFFSET + 1103, SC2WOL_LOC_ID_OFFSET + 1104],
|
||||
"Maw of the Void": [SC2WOL_LOC_ID_OFFSET + 1200, SC2WOL_LOC_ID_OFFSET + 1201, SC2WOL_LOC_ID_OFFSET + 1202, SC2WOL_LOC_ID_OFFSET + 1203, SC2WOL_LOC_ID_OFFSET + 1204, SC2WOL_LOC_ID_OFFSET + 1205],
|
||||
"Devil's Playground": [SC2WOL_LOC_ID_OFFSET + 1300, SC2WOL_LOC_ID_OFFSET + 1301, SC2WOL_LOC_ID_OFFSET + 1302],
|
||||
"Welcome to the Jungle": [SC2WOL_LOC_ID_OFFSET + 1400, SC2WOL_LOC_ID_OFFSET + 1401, SC2WOL_LOC_ID_OFFSET + 1402, SC2WOL_LOC_ID_OFFSET + 1403],
|
||||
"Breakout": [SC2WOL_LOC_ID_OFFSET + 1500, SC2WOL_LOC_ID_OFFSET + 1501, SC2WOL_LOC_ID_OFFSET + 1502],
|
||||
"Ghost of a Chance": [SC2WOL_LOC_ID_OFFSET + 1600, SC2WOL_LOC_ID_OFFSET + 1601, SC2WOL_LOC_ID_OFFSET + 1602, SC2WOL_LOC_ID_OFFSET + 1603, SC2WOL_LOC_ID_OFFSET + 1604, SC2WOL_LOC_ID_OFFSET + 1605],
|
||||
"The Great Train Robbery": [SC2WOL_LOC_ID_OFFSET + 1700, SC2WOL_LOC_ID_OFFSET + 1701, SC2WOL_LOC_ID_OFFSET + 1702, SC2WOL_LOC_ID_OFFSET + 1703],
|
||||
"Cutthroat": [SC2WOL_LOC_ID_OFFSET + 1800, SC2WOL_LOC_ID_OFFSET + 1801, SC2WOL_LOC_ID_OFFSET + 1802, SC2WOL_LOC_ID_OFFSET + 1803, SC2WOL_LOC_ID_OFFSET + 1804],
|
||||
"Engine of Destruction": [SC2WOL_LOC_ID_OFFSET + 1900, SC2WOL_LOC_ID_OFFSET + 1901, SC2WOL_LOC_ID_OFFSET + 1902, SC2WOL_LOC_ID_OFFSET + 1903, SC2WOL_LOC_ID_OFFSET + 1904, SC2WOL_LOC_ID_OFFSET + 1905],
|
||||
"Media Blitz": [SC2WOL_LOC_ID_OFFSET + 2000, SC2WOL_LOC_ID_OFFSET + 2001, SC2WOL_LOC_ID_OFFSET + 2002, SC2WOL_LOC_ID_OFFSET + 2003, SC2WOL_LOC_ID_OFFSET + 2004],
|
||||
"Piercing the Shroud": [SC2WOL_LOC_ID_OFFSET + 2100, SC2WOL_LOC_ID_OFFSET + 2101, SC2WOL_LOC_ID_OFFSET + 2102, SC2WOL_LOC_ID_OFFSET + 2103, SC2WOL_LOC_ID_OFFSET + 2104, SC2WOL_LOC_ID_OFFSET + 2105],
|
||||
"Whispers of Doom": [SC2WOL_LOC_ID_OFFSET + 2200, SC2WOL_LOC_ID_OFFSET + 2201, SC2WOL_LOC_ID_OFFSET + 2202, SC2WOL_LOC_ID_OFFSET + 2203],
|
||||
"A Sinister Turn": [SC2WOL_LOC_ID_OFFSET + 2300, SC2WOL_LOC_ID_OFFSET + 2301, SC2WOL_LOC_ID_OFFSET + 2302, SC2WOL_LOC_ID_OFFSET + 2303],
|
||||
"Echoes of the Future": [SC2WOL_LOC_ID_OFFSET + 2400, SC2WOL_LOC_ID_OFFSET + 2401, SC2WOL_LOC_ID_OFFSET + 2402],
|
||||
"In Utter Darkness": [SC2WOL_LOC_ID_OFFSET + 2500, SC2WOL_LOC_ID_OFFSET + 2501, SC2WOL_LOC_ID_OFFSET + 2502],
|
||||
"Gates of Hell": [SC2WOL_LOC_ID_OFFSET + 2600, SC2WOL_LOC_ID_OFFSET + 2601],
|
||||
"Belly of the Beast": [SC2WOL_LOC_ID_OFFSET + 2700, SC2WOL_LOC_ID_OFFSET + 2701, SC2WOL_LOC_ID_OFFSET + 2702, SC2WOL_LOC_ID_OFFSET + 2703],
|
||||
"Shatter the Sky": [SC2WOL_LOC_ID_OFFSET + 2800, SC2WOL_LOC_ID_OFFSET + 2801, SC2WOL_LOC_ID_OFFSET + 2802, SC2WOL_LOC_ID_OFFSET + 2803, SC2WOL_LOC_ID_OFFSET + 2804, SC2WOL_LOC_ID_OFFSET + 2805],
|
||||
}
|
||||
|
||||
display_data = {}
|
||||
|
||||
# Determine display for progressive items
|
||||
progressive_items = {
|
||||
"Progressive Infantry Weapon": 100 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"Progressive Infantry Armor": 102 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"Progressive Vehicle Weapon": 103 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"Progressive Vehicle Armor": 104 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"Progressive Ship Weapon": 105 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"Progressive Ship Armor": 106 + SC2WOL_ITEM_ID_OFFSET
|
||||
}
|
||||
progressive_names = {
|
||||
"Progressive Infantry Weapon": ["Infantry Weapons Level 1", "Infantry Weapons Level 1", "Infantry Weapons Level 2", "Infantry Weapons Level 3"],
|
||||
"Progressive Infantry Armor": ["Infantry Armor Level 1", "Infantry Armor Level 1", "Infantry Armor Level 2", "Infantry Armor Level 3"],
|
||||
"Progressive Vehicle Weapon": ["Vehicle Weapons Level 1", "Vehicle Weapons Level 1", "Vehicle Weapons Level 2", "Vehicle Weapons Level 3"],
|
||||
"Progressive Vehicle Armor": ["Vehicle Armor Level 1", "Vehicle Armor Level 1", "Vehicle Armor Level 2", "Vehicle Armor Level 3"],
|
||||
"Progressive Ship Weapon": ["Ship Weapons Level 1", "Ship Weapons Level 1", "Ship Weapons Level 2", "Ship Weapons Level 3"],
|
||||
"Progressive Ship Armor": ["Ship Armor Level 1", "Ship Armor Level 1", "Ship Armor Level 2", "Ship Armor Level 3"]
|
||||
}
|
||||
for item_name, item_id in progressive_items.items():
|
||||
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
|
||||
display_name = progressive_names[item_name][level]
|
||||
base_name = item_name.split(maxsplit=1)[1].lower().replace(' ', '_')
|
||||
display_data[base_name + "_level"] = level
|
||||
display_data[base_name + "_url"] = icons[display_name]
|
||||
|
||||
# Multi-items
|
||||
multi_items = {
|
||||
"+15 Starting Minerals": 800 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"+15 Starting Vespene": 801 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"+2 Starting Supply": 802 + SC2WOL_ITEM_ID_OFFSET
|
||||
}
|
||||
for item_name, item_id in multi_items.items():
|
||||
base_name = item_name.split()[-1].lower()
|
||||
count = inventory[item_id]
|
||||
if base_name == "supply":
|
||||
count = count * 2
|
||||
display_data[base_name + "_count"] = count
|
||||
else:
|
||||
count = count * 15
|
||||
display_data[base_name + "_count"] = count
|
||||
|
||||
# Victory condition
|
||||
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
|
||||
display_data['game_finished'] = game_state == 30
|
||||
|
||||
# Turn location IDs into mission objective counts
|
||||
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
|
||||
lookup_name = lambda id: lookup_any_location_id_to_name[id]
|
||||
location_info = {mission_name: {lookup_name(id): (id in checked_locations) for id in mission_locations if id in set(locations[player])} for mission_name, mission_locations in sc2wol_location_ids.items()}
|
||||
checks_done = {mission_name: len([id for id in mission_locations if id in checked_locations and id in set(locations[player])]) for mission_name, mission_locations in sc2wol_location_ids.items()}
|
||||
checks_done['Total'] = len(checked_locations)
|
||||
checks_in_area = {mission_name: len([id for id in mission_locations if id in set(locations[player])]) for mission_name, mission_locations in sc2wol_location_ids.items()}
|
||||
checks_in_area['Total'] = sum(checks_in_area.values())
|
||||
|
||||
return render_template("sc2wolTracker.html",
|
||||
inventory=inventory, icons=icons,
|
||||
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
|
||||
id in lookup_any_item_id_to_name},
|
||||
player=player, team=team, room=room, player_name=playerName,
|
||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
||||
**display_data)
|
||||
|
||||
def __renderChecksfinder(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, playerName: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict, saving_second: int) -> str:
|
||||
|
||||
icons = {
|
||||
"Checks Available": "https://0rganics.org/archipelago/cf/spr_tiles_3.png",
|
||||
"Map Width": "https://0rganics.org/archipelago/cf/spr_tiles_4.png",
|
||||
"Map Height": "https://0rganics.org/archipelago/cf/spr_tiles_5.png",
|
||||
"Map Bombs": "https://0rganics.org/archipelago/cf/spr_tiles_6.png",
|
||||
|
||||
"Nothing": "",
|
||||
}
|
||||
|
||||
checksfinder_location_ids = {
|
||||
"Tile 1": 81000,
|
||||
"Tile 2": 81001,
|
||||
"Tile 3": 81002,
|
||||
"Tile 4": 81003,
|
||||
"Tile 5": 81004,
|
||||
"Tile 6": 81005,
|
||||
"Tile 7": 81006,
|
||||
"Tile 8": 81007,
|
||||
"Tile 9": 81008,
|
||||
"Tile 10": 81009,
|
||||
"Tile 11": 81010,
|
||||
"Tile 12": 81011,
|
||||
"Tile 13": 81012,
|
||||
"Tile 14": 81013,
|
||||
"Tile 15": 81014,
|
||||
"Tile 16": 81015,
|
||||
"Tile 17": 81016,
|
||||
"Tile 18": 81017,
|
||||
"Tile 19": 81018,
|
||||
"Tile 20": 81019,
|
||||
"Tile 21": 81020,
|
||||
"Tile 22": 81021,
|
||||
"Tile 23": 81022,
|
||||
"Tile 24": 81023,
|
||||
"Tile 25": 81024,
|
||||
}
|
||||
|
||||
display_data = {}
|
||||
|
||||
# Multi-items
|
||||
multi_items = {
|
||||
"Map Width": 80000,
|
||||
"Map Height": 80001,
|
||||
"Map Bombs": 80002
|
||||
}
|
||||
for item_name, item_id in multi_items.items():
|
||||
base_name = item_name.split()[-1].lower()
|
||||
count = inventory[item_id]
|
||||
display_data[base_name + "_count"] = count
|
||||
display_data[base_name + "_display"] = count + 5
|
||||
|
||||
# Get location info
|
||||
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
|
||||
lookup_name = lambda id: lookup_any_location_id_to_name[id]
|
||||
location_info = {tile_name: {lookup_name(tile_location): (tile_location in checked_locations)} for tile_name, tile_location in checksfinder_location_ids.items() if tile_location in set(locations[player])}
|
||||
checks_done = {tile_name: len([tile_location]) for tile_name, tile_location in checksfinder_location_ids.items() if tile_location in checked_locations and tile_location in set(locations[player])}
|
||||
checks_done['Total'] = len(checked_locations)
|
||||
checks_in_area = checks_done
|
||||
|
||||
# Calculate checks available
|
||||
display_data["checks_unlocked"] = min(display_data["width_count"] + display_data["height_count"] + display_data["bombs_count"] + 5, 25)
|
||||
display_data["checks_available"] = max(display_data["checks_unlocked"] - len(checked_locations), 0)
|
||||
|
||||
# Victory condition
|
||||
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
|
||||
display_data['game_finished'] = game_state == 30
|
||||
|
||||
return render_template("checksfinderTracker.html",
|
||||
inventory=inventory, icons=icons,
|
||||
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
|
||||
id in lookup_any_item_id_to_name},
|
||||
player=player, team=team, room=room, player_name=playerName,
|
||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
||||
**display_data)
|
||||
|
||||
def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, playerName: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int],
|
||||
saving_second: int, custom_locations: Dict[int, str], custom_items: Dict[int, str]) -> str:
|
||||
|
||||
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
|
||||
player_received_items = {}
|
||||
if multisave.get('version', 0) > 0:
|
||||
# add numbering to all items but starter_inventory
|
||||
ordered_items = multisave.get('received_items', {}).get((team, player, True), [])
|
||||
else:
|
||||
ordered_items = multisave.get('received_items', {}).get((team, player), [])
|
||||
|
||||
# add numbering to all items but starter_inventory
|
||||
for order_index, networkItem in enumerate(ordered_items, start=1):
|
||||
player_received_items[networkItem.item] = order_index
|
||||
|
||||
return render_template("genericTracker.html",
|
||||
inventory=inventory,
|
||||
player=player, team=team, room=room, player_name=playerName,
|
||||
checked_locations=checked_locations,
|
||||
not_checked_locations=set(locations[player]) - checked_locations,
|
||||
received_items=player_received_items)
|
||||
inventory=inventory,
|
||||
player=player, team=team, room=room, player_name=playerName,
|
||||
checked_locations=checked_locations,
|
||||
not_checked_locations=set(locations[player]) - checked_locations,
|
||||
received_items=player_received_items, saving_second=saving_second,
|
||||
custom_items=custom_items, custom_locations=custom_locations)
|
||||
|
||||
|
||||
@app.route('/tracker/<suuid:tracker>')
|
||||
@cache.memoize(timeout=60) # multisave is currently created at most every minute
|
||||
def getTracker(tracker: UUID):
|
||||
def get_enabled_multiworld_trackers(room: Room, current: str):
|
||||
enabled = [
|
||||
{
|
||||
"name": "Generic",
|
||||
"endpoint": "get_multiworld_tracker",
|
||||
"current": current == "Generic"
|
||||
}
|
||||
]
|
||||
for game_name, endpoint in multi_trackers.items():
|
||||
if any(slot.game == game_name for slot in room.seed.slots) or current == game_name:
|
||||
enabled.append({
|
||||
"name": game_name,
|
||||
"endpoint": endpoint.__name__,
|
||||
"current": current == game_name}
|
||||
)
|
||||
return enabled
|
||||
|
||||
|
||||
def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[str, typing.Any]]:
|
||||
room: Room = Room.get(tracker=tracker)
|
||||
if not room:
|
||||
abort(404)
|
||||
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
|
||||
precollected_items, games, slot_data, groups = get_static_room_data(room)
|
||||
return None
|
||||
|
||||
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1) if playernumber not in groups}
|
||||
for teamnumber, team in enumerate(names)}
|
||||
locations, names, use_door_tracker, checks_in_area, player_location_to_area, \
|
||||
precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \
|
||||
get_static_room_data(room)
|
||||
|
||||
checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations}
|
||||
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
|
||||
for teamnumber, team in enumerate(names)}
|
||||
|
||||
percent_total_checks_done = {teamnumber: {playernumber: 0
|
||||
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
|
||||
for teamnumber, team in enumerate(names)}
|
||||
|
||||
hints = {team: set() for team in range(len(names))}
|
||||
if room.multisave:
|
||||
multisave = restricted_loads(room.multisave)
|
||||
@@ -970,6 +1358,126 @@ def getTracker(tracker: UUID):
|
||||
for (team, slot), slot_hints in multisave["hints"].items():
|
||||
hints[team] |= set(slot_hints)
|
||||
|
||||
for (team, player), locations_checked in multisave.get("location_checks", {}).items():
|
||||
if player in groups:
|
||||
continue
|
||||
player_locations = locations[player]
|
||||
checks_done[team][player]["Total"] = sum(1 for loc in locations_checked if loc in player_locations)
|
||||
percent_total_checks_done[team][player] = int(checks_done[team][player]["Total"] /
|
||||
checks_in_area[player]["Total"] * 100) \
|
||||
if checks_in_area[player]["Total"] else 100
|
||||
|
||||
activity_timers = {}
|
||||
now = datetime.datetime.utcnow()
|
||||
for (team, player), timestamp in multisave.get("client_activity_timers", []):
|
||||
activity_timers[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp)
|
||||
|
||||
player_names = {}
|
||||
for team, names in enumerate(names):
|
||||
for player, name in enumerate(names, 1):
|
||||
player_names[(team, player)] = name
|
||||
long_player_names = player_names.copy()
|
||||
for (team, player), alias in multisave.get("name_aliases", {}).items():
|
||||
player_names[(team, player)] = alias
|
||||
long_player_names[(team, player)] = f"{alias} ({long_player_names[(team, player)]})"
|
||||
|
||||
video = {}
|
||||
for (team, player), data in multisave.get("video", []):
|
||||
video[(team, player)] = data
|
||||
|
||||
return dict(player_names=player_names, room=room, checks_done=checks_done,
|
||||
percent_total_checks_done=percent_total_checks_done, checks_in_area=checks_in_area,
|
||||
activity_timers=activity_timers, video=video, hints=hints,
|
||||
long_player_names=long_player_names,
|
||||
multisave=multisave, precollected_items=precollected_items, groups=groups,
|
||||
locations=locations, games=games)
|
||||
|
||||
|
||||
def _get_inventory_data(data: typing.Dict[str, typing.Any]) -> typing.Dict[int, typing.Dict[int, int]]:
|
||||
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in team_data}
|
||||
for teamnumber, team_data in data["checks_done"].items()}
|
||||
|
||||
groups = data["groups"]
|
||||
|
||||
for (team, player), locations_checked in data["multisave"].get("location_checks", {}).items():
|
||||
if player in data["groups"]:
|
||||
continue
|
||||
player_locations = data["locations"][player]
|
||||
precollected = data["precollected_items"][player]
|
||||
for item_id in precollected:
|
||||
inventory[team][player][item_id] += 1
|
||||
for location in locations_checked:
|
||||
item_id, recipient, flags = player_locations[location]
|
||||
recipients = groups.get(recipient, [recipient])
|
||||
for recipient in recipients:
|
||||
inventory[team][recipient][item_id] += 1
|
||||
return inventory
|
||||
|
||||
|
||||
@app.route('/tracker/<suuid:tracker>')
|
||||
@cache.memoize(timeout=60) # multisave is currently created at most every minute
|
||||
def get_multiworld_tracker(tracker: UUID):
|
||||
data = _get_multiworld_tracker_data(tracker)
|
||||
if not data:
|
||||
abort(404)
|
||||
|
||||
data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Generic")
|
||||
|
||||
return render_template("multiTracker.html", **data)
|
||||
|
||||
|
||||
@app.route('/tracker/<suuid:tracker>/Factorio')
|
||||
@cache.memoize(timeout=60) # multisave is currently created at most every minute
|
||||
def get_Factorio_multiworld_tracker(tracker: UUID):
|
||||
data = _get_multiworld_tracker_data(tracker)
|
||||
if not data:
|
||||
abort(404)
|
||||
|
||||
data["inventory"] = _get_inventory_data(data)
|
||||
data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Factorio")
|
||||
|
||||
return render_template("multiFactorioTracker.html", **data)
|
||||
|
||||
|
||||
@app.route('/tracker/<suuid:tracker>/A Link to the Past')
|
||||
@cache.memoize(timeout=60) # multisave is currently created at most every minute
|
||||
def get_LttP_multiworld_tracker(tracker: UUID):
|
||||
room: Room = Room.get(tracker=tracker)
|
||||
if not room:
|
||||
abort(404)
|
||||
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
|
||||
precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \
|
||||
get_static_room_data(room)
|
||||
|
||||
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1) if
|
||||
playernumber not in groups}
|
||||
for teamnumber, team in enumerate(names)}
|
||||
|
||||
checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations}
|
||||
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
|
||||
for teamnumber, team in enumerate(names)}
|
||||
|
||||
percent_total_checks_done = {teamnumber: {playernumber: 0
|
||||
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
|
||||
for teamnumber, team in enumerate(names)}
|
||||
|
||||
hints = {team: set() for team in range(len(names))}
|
||||
if room.multisave:
|
||||
multisave = restricted_loads(room.multisave)
|
||||
else:
|
||||
multisave = {}
|
||||
if "hints" in multisave:
|
||||
for (team, slot), slot_hints in multisave["hints"].items():
|
||||
hints[team] |= set(slot_hints)
|
||||
|
||||
def attribute_item(team: int, recipient: int, item: int):
|
||||
nonlocal inventory
|
||||
target_item = links.get(item, item)
|
||||
if item in levels: # non-progressive
|
||||
inventory[team][recipient][target_item] = max(inventory[team][recipient][target_item], levels[item])
|
||||
else:
|
||||
inventory[team][recipient][target_item] += 1
|
||||
|
||||
for (team, player), locations_checked in multisave.get("location_checks", {}).items():
|
||||
if player in groups:
|
||||
continue
|
||||
@@ -977,17 +1485,19 @@ def getTracker(tracker: UUID):
|
||||
if precollected_items:
|
||||
precollected = precollected_items[player]
|
||||
for item_id in precollected:
|
||||
attribute_item(inventory, team, player, item_id)
|
||||
attribute_item(team, player, item_id)
|
||||
for location in locations_checked:
|
||||
if location not in player_locations or location not in player_location_to_area[player]:
|
||||
continue
|
||||
|
||||
item, recipient, flags = player_locations[location]
|
||||
|
||||
if recipient in names:
|
||||
attribute_item(inventory, team, recipient, item)
|
||||
checks_done[team][player][player_location_to_area[player][location]] += 1
|
||||
checks_done[team][player]["Total"] += 1
|
||||
recipients = groups.get(recipient, [recipient])
|
||||
for recipient in recipients:
|
||||
attribute_item(team, recipient, item)
|
||||
checks_done[team][player][player_location_to_area[player][location]] += 1
|
||||
checks_done[team][player]["Total"] += 1
|
||||
percent_total_checks_done[team][player] = int(
|
||||
checks_done[team][player]["Total"] / seed_checks_in_area[player]["Total"] * 100) if \
|
||||
seed_checks_in_area[player]["Total"] else 100
|
||||
|
||||
for (team, player), game_state in multisave.get("client_game_state", {}).items():
|
||||
if player in groups:
|
||||
@@ -1029,14 +1539,19 @@ def getTracker(tracker: UUID):
|
||||
for (team, player), data in multisave.get("video", []):
|
||||
video[(team, player)] = data
|
||||
|
||||
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name,
|
||||
enabled_multiworld_trackers = get_enabled_multiworld_trackers(room, "A Link to the Past")
|
||||
|
||||
return render_template("lttpMultiTracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name,
|
||||
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
|
||||
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons,
|
||||
multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas,
|
||||
checks_in_area=seed_checks_in_area, activity_timers=activity_timers,
|
||||
multi_items=multi_items, checks_done=checks_done,
|
||||
percent_total_checks_done=percent_total_checks_done,
|
||||
ordered_areas=ordered_areas, checks_in_area=seed_checks_in_area,
|
||||
activity_timers=activity_timers,
|
||||
key_locations=group_key_locations, small_key_ids=small_key_ids, big_key_ids=big_key_ids,
|
||||
video=video, big_key_locations=group_big_key_locations,
|
||||
hints=hints, long_player_names=long_player_names)
|
||||
hints=hints, long_player_names=long_player_names,
|
||||
enabled_multiworld_trackers=enabled_multiworld_trackers)
|
||||
|
||||
|
||||
game_specific_trackers: typing.Dict[str, typing.Callable] = {
|
||||
@@ -1044,5 +1559,12 @@ game_specific_trackers: typing.Dict[str, typing.Callable] = {
|
||||
"Ocarina of Time": __renderOoTTracker,
|
||||
"Timespinner": __renderTimespinnerTracker,
|
||||
"A Link to the Past": __renderAlttpTracker,
|
||||
"Super Metroid": __renderSuperMetroidTracker
|
||||
"ChecksFinder": __renderChecksfinder,
|
||||
"Super Metroid": __renderSuperMetroidTracker,
|
||||
"Starcraft 2 Wings of Liberty": __renderSC2WoLTracker
|
||||
}
|
||||
|
||||
multi_trackers: typing.Dict[str, typing.Callable] = {
|
||||
"A Link to the Past": get_LttP_multiworld_tracker,
|
||||
"Factorio": get_Factorio_multiworld_tracker,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import uuid
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
from flask import request, flash, redirect, url_for, session, render_template, Markup
|
||||
from pony.orm import flush, select
|
||||
|
||||
import MultiServer
|
||||
@@ -15,68 +15,41 @@ from worlds.Files import AutoPatchRegister
|
||||
from . import app
|
||||
from .models import Seed, Room, Slot
|
||||
|
||||
banned_zip_contents = (".sfc",)
|
||||
banned_zip_contents = (".sfc", ".z64", ".n64", ".sms", ".gb")
|
||||
|
||||
|
||||
def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, sid=None):
|
||||
if not owner:
|
||||
owner = session["_id"]
|
||||
infolist = zfile.infolist()
|
||||
if all(file.filename.endswith((".yaml", ".yml")) or file.is_dir() for file in infolist):
|
||||
flash(Markup("Error: Your .zip file only contains .yaml files. "
|
||||
'Did you mean to <a href="/generate">generate a game</a>?'))
|
||||
return
|
||||
slots: typing.Set[Slot] = set()
|
||||
spoiler = ""
|
||||
files = {}
|
||||
multidata = None
|
||||
|
||||
# Load files.
|
||||
for file in infolist:
|
||||
handler = AutoPatchRegister.get_handler(file.filename)
|
||||
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."
|
||||
|
||||
# AP Container
|
||||
elif handler:
|
||||
raw = zfile.open(file, "r").read()
|
||||
patch = handler(BytesIO(raw))
|
||||
patch.read()
|
||||
slots.add(Slot(data=raw,
|
||||
player_name=patch.player_name,
|
||||
player_id=patch.player,
|
||||
game=patch.game))
|
||||
|
||||
elif file.filename.endswith(".apmc"):
|
||||
data = zfile.open(file, "r").read()
|
||||
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
|
||||
slots.add(Slot(data=data,
|
||||
player_name=metadata["player_name"],
|
||||
player_id=metadata["player_id"],
|
||||
game="Minecraft"))
|
||||
|
||||
elif file.filename.endswith(".apv6"):
|
||||
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3)
|
||||
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
|
||||
player_id=int(slot_id[1:]), game="VVVVVV"))
|
||||
|
||||
elif file.filename.endswith(".apsm64ex"):
|
||||
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3)
|
||||
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
|
||||
player_id=int(slot_id[1:]), game="Super Mario 64"))
|
||||
|
||||
elif file.filename.endswith(".zip"):
|
||||
# Factorio mods need a specific name or they do not function
|
||||
_, seed_name, slot_id, slot_name = file.filename.rsplit("_", 1)[0].split("-", 3)
|
||||
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
|
||||
player_id=int(slot_id[1:]), game="Factorio"))
|
||||
|
||||
elif file.filename.endswith(".apz5"):
|
||||
# .apz5 must be named specifically since they don't contain any metadata
|
||||
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3)
|
||||
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
|
||||
player_id=int(slot_id[1:]), game="Ocarina of Time"))
|
||||
|
||||
elif file.filename.endswith(".json"):
|
||||
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('-', 3)
|
||||
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
|
||||
player_id=int(slot_id[1:]), game="Dark Souls III"))
|
||||
patch = handler(BytesIO(data))
|
||||
patch.read()
|
||||
files[patch.player] = data
|
||||
|
||||
# Spoiler
|
||||
elif file.filename.endswith(".txt"):
|
||||
spoiler = zfile.open(file, "r").read().decode("utf-8-sig")
|
||||
|
||||
# Multi-data
|
||||
elif file.filename.endswith(".archipelago"):
|
||||
try:
|
||||
multidata = zfile.open(file).read()
|
||||
@@ -84,17 +57,36 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
||||
flash("Could not load multidata. File may be corrupted or incompatible.")
|
||||
multidata = None
|
||||
|
||||
# Minecraft
|
||||
elif file.filename.endswith(".apmc"):
|
||||
data = zfile.open(file, "r").read()
|
||||
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
|
||||
files[metadata["player_id"]] = data
|
||||
|
||||
# Factorio
|
||||
elif file.filename.endswith(".zip"):
|
||||
_, _, slot_id, *_ = file.filename.split('_')[0].split('-', 3)
|
||||
data = zfile.open(file, "r").read()
|
||||
files[int(slot_id[1:])] = data
|
||||
|
||||
# All other files using the standard MultiWorld.get_out_file_name_base method
|
||||
else:
|
||||
_, _, slot_id, *_ = file.filename.split('.')[0].split('_', 3)
|
||||
data = zfile.open(file, "r").read()
|
||||
files[int(slot_id[1:])] = data
|
||||
|
||||
# Load multi data.
|
||||
if multidata:
|
||||
decompressed_multidata = MultiServer.Context.decompress(multidata)
|
||||
if "slot_info" in decompressed_multidata:
|
||||
player_names = {slot.player_name for slot in slots}
|
||||
leftover_names: typing.Dict[int, NetworkSlot] = {
|
||||
slot_id: slot_info for slot_id, slot_info in decompressed_multidata["slot_info"].items()
|
||||
if slot_info.name not in player_names and slot_info.type != SlotType.group}
|
||||
newslots = [(Slot(data=None, player_name=slot_info.name, player_id=slot, game=slot_info.game))
|
||||
for slot, slot_info in leftover_names.items()]
|
||||
for slot in newslots:
|
||||
slots.add(slot)
|
||||
for slot, slot_info in decompressed_multidata["slot_info"].items():
|
||||
# Ignore Player Groups (e.g. item links)
|
||||
if slot_info.type == SlotType.group:
|
||||
continue
|
||||
slots.add(Slot(data=files.get(slot, None),
|
||||
player_name=slot_info.name,
|
||||
player_id=slot,
|
||||
game=slot_info.game))
|
||||
|
||||
flush() # commit slots
|
||||
|
||||
|
||||
393
Zelda1Client.py
Normal file
393
Zelda1Client.py
Normal file
@@ -0,0 +1,393 @@
|
||||
# Based (read: copied almost wholesale and edited) off the FF1 Client.
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import typing
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
from typing import List
|
||||
|
||||
import Utils
|
||||
from Utils import async_start
|
||||
from worlds import lookup_any_location_id_to_name
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
|
||||
get_base_parser
|
||||
|
||||
from worlds.tloz.Items import item_game_ids
|
||||
from worlds.tloz.Locations import location_ids
|
||||
from worlds.tloz import Items, Locations, Rom
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart Zelda_connector.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure Zelda_connector.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart Zelda_connector.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
DISPLAY_MSGS = True
|
||||
|
||||
item_ids = item_game_ids
|
||||
location_ids = location_ids
|
||||
items_by_id = {id: item for item, id in item_ids.items()}
|
||||
locations_by_id = {id: location for location, id in location_ids.items()}
|
||||
|
||||
|
||||
class ZeldaCommandProcessor(ClientCommandProcessor):
|
||||
|
||||
def _cmd_nes(self):
|
||||
"""Check NES Connection State"""
|
||||
if isinstance(self.ctx, ZeldaContext):
|
||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
||||
|
||||
def _cmd_toggle_msgs(self):
|
||||
"""Toggle displaying messages in bizhawk"""
|
||||
global DISPLAY_MSGS
|
||||
DISPLAY_MSGS = not DISPLAY_MSGS
|
||||
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
|
||||
|
||||
|
||||
class ZeldaContext(CommonContext):
|
||||
command_processor = ZeldaCommandProcessor
|
||||
items_handling = 0b101 # get sent remote and starting items
|
||||
# Infinite Hyrule compatibility
|
||||
overworld_item = 0x5F
|
||||
armos_item = 0x24
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.bonus_items = []
|
||||
self.nes_streams: (StreamReader, StreamWriter) = None
|
||||
self.nes_sync_task = None
|
||||
self.messages = {}
|
||||
self.locations_array = None
|
||||
self.nes_status = CONNECTION_INITIAL_STATUS
|
||||
self.game = 'The Legend of Zelda'
|
||||
self.awaiting_rom = False
|
||||
self.shop_slots_left = 0
|
||||
self.shop_slots_middle = 0
|
||||
self.shop_slots_right = 0
|
||||
self.shop_slots = [self.shop_slots_left, self.shop_slots_middle, self.shop_slots_right]
|
||||
self.slot_data = dict()
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(ZeldaContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to NES to get Player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
if DISPLAY_MSGS:
|
||||
self.messages[(time.time(), msg_id)] = msg
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.slot_data = args.get("slot_data", {})
|
||||
asyncio.create_task(parse_locations(self.locations_array, self, True))
|
||||
elif cmd == 'Print':
|
||||
msg = args['text']
|
||||
if ': !' not in msg:
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if self.ui:
|
||||
self.ui.print_json(copy.deepcopy(args["data"]))
|
||||
else:
|
||||
text = self.jsontotextparser(copy.deepcopy(args["data"]))
|
||||
logger.info(text)
|
||||
relevant = args.get("type", None) in {"Hint", "ItemSend"}
|
||||
if relevant:
|
||||
item = args["item"]
|
||||
# goes to this world
|
||||
if self.slot_concerns_self(args["receiving"]):
|
||||
relevant = True
|
||||
# found in this world
|
||||
elif self.slot_concerns_self(item.player):
|
||||
relevant = True
|
||||
# not related
|
||||
else:
|
||||
relevant = False
|
||||
if relevant:
|
||||
item = args["item"]
|
||||
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
|
||||
self._set_message(msg, item.item)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class ZeldaManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Zelda 1 Client"
|
||||
|
||||
self.ui = ZeldaManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
def get_payload(ctx: ZeldaContext):
|
||||
current_time = time.time()
|
||||
bonus_items = [item for item in ctx.bonus_items]
|
||||
return json.dumps(
|
||||
{
|
||||
"items": [item.item for item in ctx.items_received],
|
||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
||||
if key[0] > current_time - 10},
|
||||
"shops": {
|
||||
"left": ctx.shop_slots_left,
|
||||
"middle": ctx.shop_slots_middle,
|
||||
"right": ctx.shop_slots_right
|
||||
},
|
||||
"bonusItems": bonus_items
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def reconcile_shops(ctx: ZeldaContext):
|
||||
checked_location_names = [lookup_any_location_id_to_name[location] for location in ctx.checked_locations]
|
||||
shops = [location for location in checked_location_names if "Shop" in location]
|
||||
left_slots = [shop for shop in shops if "Left" in shop]
|
||||
middle_slots = [shop for shop in shops if "Middle" in shop]
|
||||
right_slots = [shop for shop in shops if "Right" in shop]
|
||||
for shop in left_slots:
|
||||
ctx.shop_slots_left |= get_shop_bit_from_name(shop)
|
||||
for shop in middle_slots:
|
||||
ctx.shop_slots_middle |= get_shop_bit_from_name(shop)
|
||||
for shop in right_slots:
|
||||
ctx.shop_slots_right |= get_shop_bit_from_name(shop)
|
||||
|
||||
|
||||
def get_shop_bit_from_name(location_name):
|
||||
if "Potion" in location_name:
|
||||
return Rom.potion_shop
|
||||
elif "Arrow" in location_name:
|
||||
return Rom.arrow_shop
|
||||
elif "Shield" in location_name:
|
||||
return Rom.shield_shop
|
||||
elif "Ring" in location_name:
|
||||
return Rom.ring_shop
|
||||
elif "Candle" in location_name:
|
||||
return Rom.candle_shop
|
||||
elif "Take" in location_name:
|
||||
return Rom.take_any
|
||||
return 0 # this should never be hit
|
||||
|
||||
|
||||
async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone="None"):
|
||||
if locations_array == ctx.locations_array and not force:
|
||||
return
|
||||
else:
|
||||
# print("New values")
|
||||
ctx.locations_array = locations_array
|
||||
locations_checked = []
|
||||
location = None
|
||||
for location in ctx.missing_locations:
|
||||
location_name = lookup_any_location_id_to_name[location]
|
||||
|
||||
if location_name in Locations.overworld_locations and zone == "overworld":
|
||||
status = locations_array[Locations.major_location_offsets[location_name]]
|
||||
if location_name == "Ocean Heart Container":
|
||||
status = locations_array[ctx.overworld_item]
|
||||
if location_name == "Armos Knights":
|
||||
status = locations_array[ctx.armos_item]
|
||||
if status & 0x10:
|
||||
ctx.locations_checked.add(location)
|
||||
locations_checked.append(location)
|
||||
elif location_name in Locations.underworld1_locations and zone == "underworld1":
|
||||
status = locations_array[Locations.floor_location_game_offsets_early[location_name]]
|
||||
if status & 0x10:
|
||||
ctx.locations_checked.add(location)
|
||||
locations_checked.append(location)
|
||||
elif location_name in Locations.underworld2_locations and zone == "underworld2":
|
||||
status = locations_array[Locations.floor_location_game_offsets_late[location_name]]
|
||||
if status & 0x10:
|
||||
ctx.locations_checked.add(location)
|
||||
locations_checked.append(location)
|
||||
elif (location_name in Locations.shop_locations or "Take" in location_name) and zone == "caves":
|
||||
shop_bit = get_shop_bit_from_name(location_name)
|
||||
slot = 0
|
||||
context_slot = 0
|
||||
if "Left" in location_name:
|
||||
slot = "slot1"
|
||||
context_slot = 0
|
||||
elif "Middle" in location_name:
|
||||
slot = "slot2"
|
||||
context_slot = 1
|
||||
elif "Right" in location_name:
|
||||
slot = "slot3"
|
||||
context_slot = 2
|
||||
if locations_array[slot] & shop_bit > 0:
|
||||
locations_checked.append(location)
|
||||
ctx.shop_slots[context_slot] |= shop_bit
|
||||
if locations_array["takeAnys"] and locations_array["takeAnys"] >= 4:
|
||||
if "Take Any" in location_name:
|
||||
short_name = None
|
||||
if "Left" in location_name:
|
||||
short_name = "TakeAnyLeft"
|
||||
elif "Middle" in location_name:
|
||||
short_name = "TakeAnyMiddle"
|
||||
elif "Right" in location_name:
|
||||
short_name = "TakeAnyRight"
|
||||
if short_name is not None:
|
||||
item_code = ctx.slot_data[short_name]
|
||||
if item_code > 0:
|
||||
ctx.bonus_items.append(item_code)
|
||||
locations_checked.append(location)
|
||||
if locations_checked:
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "LocationChecks",
|
||||
"locations": locations_checked}
|
||||
])
|
||||
|
||||
|
||||
async def nes_sync_task(ctx: ZeldaContext):
|
||||
logger.info("Starting nes connector. Use /nes for status information")
|
||||
while not ctx.exit_event.is_set():
|
||||
error_status = None
|
||||
if ctx.nes_streams:
|
||||
(reader, writer) = ctx.nes_streams
|
||||
msg = get_payload(ctx).encode()
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to two fields:
|
||||
# 1. A keepalive response of the Players Name (always)
|
||||
# 2. An array representing the memory values of the locations area (if in game)
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
||||
data_decoded = json.loads(data.decode())
|
||||
if data_decoded["overworldHC"] is not None:
|
||||
ctx.overworld_item = data_decoded["overworldHC"]
|
||||
if data_decoded["overworldPB"] is not None:
|
||||
ctx.armos_item = data_decoded["overworldPB"]
|
||||
if data_decoded['gameMode'] == 19 and ctx.finished_game == False:
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "StatusUpdate",
|
||||
"status": 30}
|
||||
])
|
||||
ctx.finished_game = True
|
||||
if ctx.game is not None and 'overworld' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task(parse_locations(data_decoded['overworld'], ctx, False, "overworld"))
|
||||
if ctx.game is not None and 'underworld1' in data_decoded:
|
||||
asyncio.create_task(parse_locations(data_decoded['underworld1'], ctx, False, "underworld1"))
|
||||
if ctx.game is not None and 'underworld2' in data_decoded:
|
||||
asyncio.create_task(parse_locations(data_decoded['underworld2'], ctx, False, "underworld2"))
|
||||
if ctx.game is not None and 'caves' in data_decoded:
|
||||
asyncio.create_task(parse_locations(data_decoded['caves'], ctx, False, "caves"))
|
||||
if not ctx.auth:
|
||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||
if ctx.auth == '':
|
||||
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
|
||||
"the ROM using the same link but adding your slot name")
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
reconcile_shops(ctx)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to NES")
|
||||
ctx.nes_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
|
||||
elif error_status:
|
||||
ctx.nes_status = error_status
|
||||
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to NES")
|
||||
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
|
||||
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
Utils.init_logging("ZeldaClient")
|
||||
|
||||
options = Utils.get_options()
|
||||
DISPLAY_MSGS = options["tloz_options"]["display_msgs"]
|
||||
|
||||
|
||||
async def run_game(romfile: str) -> None:
|
||||
auto_start = typing.cast(typing.Union[bool, str],
|
||||
Utils.get_options()["tloz_options"].get("rom_start", True))
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif isinstance(auto_start, str) and os.path.isfile(auto_start):
|
||||
subprocess.Popen([auto_start, romfile],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def main(args):
|
||||
if args.diff_file:
|
||||
import Patch
|
||||
logging.info("Patch file was supplied. Creating nes rom..")
|
||||
meta, romfile = Patch.create_rom_file(args.diff_file)
|
||||
if "server" in meta:
|
||||
args.connect = meta["server"]
|
||||
logging.info(f"Wrote rom file to {romfile}")
|
||||
async_start(run_game(romfile))
|
||||
ctx = ZeldaContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.nes_sync_task:
|
||||
await ctx.nes_sync_task
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||
help='Path to a Archipelago Binary Patch file')
|
||||
args = parser.parse_args()
|
||||
colorama.init()
|
||||
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
@@ -48,6 +48,9 @@ class ZillionContext(CommonContext):
|
||||
command_processor: Type[ClientCommandProcessor] = ZillionCommandProcessor
|
||||
items_handling = 1 # receive items from other players
|
||||
|
||||
known_name: Optional[str]
|
||||
""" This is almost the same as `auth` except `auth` is reset to `None` when server disconnects, and this isn't. """
|
||||
|
||||
from_game: "asyncio.Queue[events.EventFromGame]"
|
||||
to_game: "asyncio.Queue[events.EventToGame]"
|
||||
ap_local_count: int
|
||||
@@ -82,6 +85,7 @@ class ZillionContext(CommonContext):
|
||||
server_address: str,
|
||||
password: str) -> None:
|
||||
super().__init__(server_address, password)
|
||||
self.known_name = None
|
||||
self.from_game = asyncio.Queue()
|
||||
self.to_game = asyncio.Queue()
|
||||
self.got_room_info = asyncio.Event()
|
||||
@@ -258,6 +262,10 @@ class ZillionContext(CommonContext):
|
||||
assert id_ in id_to_loc
|
||||
self.loc_mem_to_id[mem] = id_
|
||||
|
||||
if len(self.loc_mem_to_id) != 394:
|
||||
logger.warn("invalid Zillion `Connected` packet, "
|
||||
f"`slot_data` missing locations in `loc_mem_to_id` - len {len(self.loc_mem_to_id)}")
|
||||
|
||||
self.got_slot_data.set()
|
||||
|
||||
payload = {
|
||||
@@ -392,7 +400,8 @@ async def zillion_sync_task(ctx: ZillionContext) -> None:
|
||||
game_id = memory.get_rom_to_ram_data(ram)
|
||||
name, seed_end = name_seed_from_ram(game_id)
|
||||
if len(name):
|
||||
if name == ctx.auth:
|
||||
if name == ctx.known_name:
|
||||
ctx.auth = name
|
||||
# this is the name we know
|
||||
if ctx.server and ctx.server.socket: # type: ignore
|
||||
if ctx.got_room_info.is_set():
|
||||
@@ -435,6 +444,7 @@ async def zillion_sync_task(ctx: ZillionContext) -> None:
|
||||
memory.reset_game_state()
|
||||
|
||||
ctx.auth = name
|
||||
ctx.known_name = name
|
||||
async_start(ctx.connect())
|
||||
await asyncio.wait((
|
||||
ctx.got_room_info.wait(),
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
<TabbedPanel>
|
||||
tab_width: 200
|
||||
<TextColors>:
|
||||
# Hex-format RGB colors used in clients. Resets after an update/install.
|
||||
# To avoid, you can copy the TextColors section into a new "user.kv" next to this file
|
||||
# and it will read from there instead.
|
||||
black: "000000"
|
||||
red: "EE0000"
|
||||
green: "00FF7F" # typically a location
|
||||
yellow: "FAFAD2" # typically other slots/players
|
||||
blue: "6495ED" # typically extra info (such as entrance)
|
||||
magenta: "EE00EE" # typically your slot/player
|
||||
cyan: "00EEEE" # typically regular item
|
||||
slateblue: "6D8BE8" # typically useful item
|
||||
plum: "AF99EF" # typically progression item
|
||||
salmon: "FA8072" # typically trap item
|
||||
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
|
||||
<Label>:
|
||||
color: "FFFFFF"
|
||||
<TabbedPanel>:
|
||||
tab_width: root.width / app.tab_count
|
||||
<SelectableLabel>:
|
||||
canvas.before:
|
||||
Color:
|
||||
@@ -13,6 +30,8 @@
|
||||
font_size: dp(20)
|
||||
markup: True
|
||||
<UILog>:
|
||||
messages: 1000 # amount of messages stored in client logs.
|
||||
cols: 1
|
||||
viewclass: 'SelectableLabel'
|
||||
scroll_y: 0
|
||||
scroll_type: ["content", "bars"]
|
||||
|
||||
@@ -2,8 +2,8 @@ local socket = require("socket")
|
||||
local json = require('json')
|
||||
local math = require('math')
|
||||
|
||||
local last_modified_date = '2022-07-24' -- Should be the last modified date
|
||||
local script_version = 2
|
||||
local last_modified_date = '2022-11-27' -- Should be the last modified date
|
||||
local script_version = 3
|
||||
|
||||
--------------------------------------------------
|
||||
-- Heavily modified form of RiptideSage's tracker
|
||||
@@ -25,6 +25,9 @@ local inf_table_offset = save_context_offset + 0xEF8 -- 0x11B4C8
|
||||
|
||||
local temp_context = nil
|
||||
|
||||
local collectibles_overrides = nil
|
||||
local collectible_offsets = nil
|
||||
|
||||
-- Offsets for scenes can be found here
|
||||
-- https://wiki.cloudmodding.com/oot/Scene_Table/NTSC_1.0
|
||||
-- Each scene is 0x1c bits long, chests at 0x0, switches at 0x4, collectibles at 0xc
|
||||
@@ -40,12 +43,16 @@ end
|
||||
-- [1] is the scene id
|
||||
-- [2] is the location type, which varies as input to the function
|
||||
-- [3] is the location id within the scene, and represents the bit which was checked
|
||||
-- REORDERED IN 7.0 TO scene id - location type - 0x00 - location id
|
||||
-- Note that temp_context is 0-indexed and expected_values is 1-indexed, because consistency.
|
||||
local check_temp_context = function(expected_values)
|
||||
if temp_context[0] ~= 0x00 then return false end
|
||||
for i=1,3 do
|
||||
if temp_context[i] ~= expected_values[i] then return false end
|
||||
end
|
||||
-- if temp_context[0] ~= 0x00 then return false end
|
||||
-- for i=1,3 do
|
||||
-- if temp_context[i] ~= expected_values[i] then return false end
|
||||
-- end
|
||||
if temp_context[0] ~= expected_values[1] then return false end
|
||||
if temp_context[1] ~= expected_values[2] then return false end
|
||||
if temp_context[3] ~= expected_values[3] then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -67,7 +74,7 @@ local on_the_ground_check = function(scene_offset, bit_to_check)
|
||||
end
|
||||
|
||||
local boss_item_check = function(scene_offset)
|
||||
return chest_check(scene_offset, 0x1F)
|
||||
return on_the_ground_check(scene_offset, 0x1F)
|
||||
or check_temp_context({scene_offset, 0x00, 0x4F})
|
||||
end
|
||||
|
||||
@@ -226,6 +233,8 @@ local read_kokiri_forest_checks = function()
|
||||
checks["KF Shop Item 6"] = shop_check(0x6, 0x1)
|
||||
checks["KF Shop Item 7"] = shop_check(0x6, 0x2)
|
||||
checks["KF Shop Item 8"] = shop_check(0x6, 0x3)
|
||||
|
||||
checks["KF Shop Blue Rupee"] = on_the_ground_check(0x2D, 0x1)
|
||||
return checks
|
||||
end
|
||||
|
||||
@@ -454,7 +463,7 @@ local read_kakariko_village_checks = function()
|
||||
checks["Kak Impas House Cow"] = cow_check(0x37, 0x18)
|
||||
|
||||
checks["Kak GS Tree"] = skulltula_check(0x10, 0x5)
|
||||
checks["Kak GS Guards House"] = skulltula_check(0x10, 0x1)
|
||||
checks["Kak GS Near Gate Guard"] = skulltula_check(0x10, 0x1)
|
||||
checks["Kak GS Watchtower"] = skulltula_check(0x10, 0x2)
|
||||
checks["Kak GS Skulltula House"] = skulltula_check(0x10, 0x4)
|
||||
checks["Kak GS House Under Construction"] = skulltula_check(0x10, 0x3)
|
||||
@@ -480,7 +489,7 @@ local read_graveyard_checks = function()
|
||||
checks["Graveyard Royal Familys Tomb Chest"] = chest_check(0x41, 0x00)
|
||||
checks["Graveyard Freestanding PoH"] = on_the_ground_check(0x53, 0x4)
|
||||
checks["Graveyard Dampe Gravedigging Tour"] = on_the_ground_check(0x53, 0x8)
|
||||
checks["Graveyard Hookshot Chest"] = chest_check(0x48, 0x00)
|
||||
checks["Graveyard Dampe Race Hookshot Chest"] = chest_check(0x48, 0x00)
|
||||
checks["Graveyard Dampe Race Freestanding PoH"] = on_the_ground_check(0x48, 0x7)
|
||||
|
||||
checks["Graveyard GS Bean Patch"] = skulltula_check(0x10, 0x0)
|
||||
@@ -545,7 +554,7 @@ local read_shadow_temple_checks = function(mq_table_address)
|
||||
checks["Shadow Temple Boss Key Chest"] = chest_check(0x07, 0x0B)
|
||||
checks["Shadow Temple Invisible Floormaster Chest"] = chest_check(0x07, 0x0D)
|
||||
|
||||
checks["Shadow Temple GS Like Like Room"] = skulltula_check(0x07, 0x3)
|
||||
checks["Shadow Temple GS Invisible Blades Room"] = skulltula_check(0x07, 0x3)
|
||||
checks["Shadow Temple GS Falling Spikes Room"] = skulltula_check(0x07, 0x1)
|
||||
checks["Shadow Temple GS Single Giant Pot"] = skulltula_check(0x07, 0x0)
|
||||
checks["Shadow Temple GS Near Ship"] = skulltula_check(0x07, 0x4)
|
||||
@@ -723,9 +732,9 @@ local read_fire_temple_checks = function(mq_table_address)
|
||||
|
||||
checks["Fire Temple MQ GS Big Lava Room Open Door"] = skulltula_check(0x4, 0x0)
|
||||
checks["Fire Temple MQ GS Skull On Fire"] = skulltula_check(0x4, 0x2)
|
||||
checks["Fire Temple MQ GS Fire Wall Maze Center"] = skulltula_check(0x4, 0x3)
|
||||
checks["Fire Temple MQ GS Fire Wall Maze Side Room"] = skulltula_check(0x4, 0x4)
|
||||
checks["Fire Temple MQ GS Above Fire Wall Maze"] = skulltula_check(0x4, 0x1)
|
||||
checks["Fire Temple MQ GS Flame Maze Center"] = skulltula_check(0x4, 0x3)
|
||||
checks["Fire Temple MQ GS Flame Maze Side Room"] = skulltula_check(0x4, 0x4)
|
||||
checks["Fire Temple MQ GS Above Flame Maze"] = skulltula_check(0x4, 0x1)
|
||||
end
|
||||
|
||||
checks["Fire Temple Volvagia Heart"] = boss_item_check(0x15)
|
||||
@@ -743,6 +752,12 @@ local read_zoras_river_checks = function()
|
||||
checks["ZR Deku Scrub Grotto Front"] = scrub_sanity_check(0x15, 0x9)
|
||||
checks["ZR Deku Scrub Grotto Rear"] = scrub_sanity_check(0x15, 0x8)
|
||||
|
||||
checks["ZR Frogs Zeldas Lullaby"] = event_check(0xD, 0x1)
|
||||
checks["ZR Frogs Eponas Song"] = event_check(0xD, 0x2)
|
||||
checks["ZR Frogs Suns Song"] = event_check(0xD, 0x3)
|
||||
checks["ZR Frogs Sarias Song"] = event_check(0xD, 0x4)
|
||||
checks["ZR Frogs Song of Time"] = event_check(0xD, 0x5)
|
||||
|
||||
checks["ZR GS Tree"] = skulltula_check(0x11, 0x1)
|
||||
--NOTE: There is no GS in the soft soil. It's the only one that doesn't have one.
|
||||
checks["ZR GS Ladder"] = skulltula_check(0x11, 0x0)
|
||||
@@ -912,10 +927,10 @@ end
|
||||
|
||||
local read_gerudo_fortress_checks = function()
|
||||
local checks = {}
|
||||
checks["Hideout Jail Guard (1 Torch)"] = on_the_ground_check(0xC, 0xC)
|
||||
checks["Hideout Jail Guard (2 Torches)"] = on_the_ground_check(0xC, 0xF)
|
||||
checks["Hideout Jail Guard (3 Torches)"] = on_the_ground_check(0xC, 0xA)
|
||||
checks["Hideout Jail Guard (4 Torches)"] = on_the_ground_check(0xC, 0xE)
|
||||
checks["Hideout 1 Torch Jail Gerudo Key"] = on_the_ground_check(0xC, 0xC)
|
||||
checks["Hideout 2 Torches Jail Gerudo Key"] = on_the_ground_check(0xC, 0xF)
|
||||
checks["Hideout 3 Torches Jail Gerudo Key"] = on_the_ground_check(0xC, 0xA)
|
||||
checks["Hideout 4 Torches Jail Gerudo Key"] = on_the_ground_check(0xC, 0xE)
|
||||
checks["Hideout Gerudo Membership Card"] = membership_card_check(0xC, 0x2)
|
||||
checks["GF Chest"] = chest_check(0x5D, 0x0)
|
||||
checks["GF HBA 1000 Points"] = info_table_check(0x33, 0x0)
|
||||
@@ -1170,9 +1185,22 @@ local check_all_locations = function(mq_table_address)
|
||||
for k,v in pairs(read_ganons_castle_checks(mq_table_address)) do location_checks[k] = v end
|
||||
for k,v in pairs(read_outside_ganons_castle_checks()) do location_checks[k] = v end
|
||||
for k,v in pairs(read_song_checks()) do location_checks[k] = v end
|
||||
-- write 0 to temp context values
|
||||
mainmemory.write_u32_be(0x40002C, 0)
|
||||
mainmemory.write_u32_be(0x400030, 0)
|
||||
return location_checks
|
||||
end
|
||||
|
||||
local check_collectibles = function()
|
||||
local retval = {}
|
||||
if collectible_offsets ~= nil then
|
||||
for id, data in pairs(collectible_offsets) do
|
||||
local mem = mainmemory.readbyte(collectible_overrides + data[1] + bit.rshift(data[2], 3))
|
||||
retval[id] = bit.check(mem, data[2] % 8)
|
||||
end
|
||||
end
|
||||
return retval
|
||||
end
|
||||
|
||||
-- convenience functions
|
||||
|
||||
@@ -1557,9 +1585,10 @@ local outgoing_player_addr = coop_context + 18
|
||||
|
||||
local player_names_address = coop_context + 20
|
||||
local player_name_length = 8 -- 8 bytes
|
||||
local rom_name_location = player_names_address + 0x800
|
||||
local rom_name_location = player_names_address + 0x800 + 0x5 -- 0x800 player names, 0x5 CFG_FILE_SELECT_HASH
|
||||
|
||||
local master_quest_table_address = rando_context + (mainmemory.read_u32_be(rando_context + 0x0CE0) - 0x03480000)
|
||||
-- TODO: load dynamically from slot data
|
||||
local master_quest_table_address = rando_context + (mainmemory.read_u32_be(rando_context + 0x0E9F) - 0x03480000)
|
||||
|
||||
local save_context_addr = 0x11A5D0
|
||||
local internal_count_addr = save_context_addr + 0x90
|
||||
@@ -1568,7 +1597,7 @@ local item_queue = {}
|
||||
local first_connect = true
|
||||
local game_complete = false
|
||||
|
||||
NUM_BIG_POES_REQUIRED = mainmemory.read_u8(rando_context + 0x0CEE)
|
||||
NUM_BIG_POES_REQUIRED = mainmemory.read_u8(rando_context + 0x0EAD)
|
||||
|
||||
local bytes_to_string = function(bytes)
|
||||
local string = ''
|
||||
@@ -1718,7 +1747,7 @@ function is_game_complete()
|
||||
end
|
||||
|
||||
function deathlink_enabled()
|
||||
local death_link_flag = mainmemory.read_u16_be(coop_context + 0xA)
|
||||
local death_link_flag = mainmemory.readbyte(coop_context + 0xB)
|
||||
return death_link_flag > 0
|
||||
end
|
||||
|
||||
@@ -1774,6 +1803,13 @@ function process_block(block)
|
||||
mainmemory.write_u16_be(incoming_item_addr, item_queue[received_items_count+1])
|
||||
end
|
||||
end
|
||||
-- Record collectible data if necessary
|
||||
if collectible_overrides == nil and block['collectibleOverrides'] ~= 0 then
|
||||
collectible_overrides = mainmemory.read_u32_be(rando_context + block['collectibleOverrides']) - 0x80000000
|
||||
end
|
||||
if collectible_offsets ~= block['collectibleOffsets'] then
|
||||
collectible_offsets = block['collectibleOffsets']
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -1805,6 +1841,7 @@ function receive()
|
||||
retTable["deathlinkActive"] = deathlink_enabled()
|
||||
if InSafeState() then
|
||||
retTable["locations"] = check_all_locations(master_quest_table_address)
|
||||
retTable["collectibles"] = check_collectibles()
|
||||
retTable["isDead"] = get_death_state()
|
||||
retTable["gameComplete"] = is_game_complete()
|
||||
end
|
||||
|
||||
@@ -7,18 +7,26 @@ local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
|
||||
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
|
||||
local STATE_UNINITIALIZED = "Uninitialized"
|
||||
|
||||
local SCRIPT_VERSION = 3
|
||||
|
||||
local APIndex = 0x1A6E
|
||||
local APDeathLinkAddress = 0x00FD
|
||||
local APItemAddress = 0x00FF
|
||||
local EventFlagAddress = 0x1735
|
||||
local MissableAddress = 0x161A
|
||||
local HiddenItemsAddress = 0x16DE
|
||||
local RodAddress = 0x1716
|
||||
local InGame = 0x1A71
|
||||
local DexSanityAddress = 0x1A71
|
||||
local InGameAddress = 0x1A84
|
||||
local ClientCompatibilityAddress = 0xFF00
|
||||
|
||||
local ItemsReceived = nil
|
||||
local playerName = nil
|
||||
local seedName = nil
|
||||
|
||||
local deathlink_rec = nil
|
||||
local deathlink_send = false
|
||||
|
||||
local prevstate = ""
|
||||
local curstate = STATE_UNINITIALIZED
|
||||
local gbSocket = nil
|
||||
@@ -27,6 +35,7 @@ local frame = 0
|
||||
local u8 = nil
|
||||
local wU8 = nil
|
||||
local u16
|
||||
local compat = nil
|
||||
|
||||
local function defineMemoryFunctions()
|
||||
local memDomain = {}
|
||||
@@ -63,19 +72,6 @@ function slice (tbl, s, e)
|
||||
return new
|
||||
end
|
||||
|
||||
function processBlock(block)
|
||||
if block == nil then
|
||||
return
|
||||
end
|
||||
local itemsBlock = block["items"]
|
||||
memDomain.wram()
|
||||
if itemsBlock ~= nil then-- and u8(0x116B) ~= 0x00 then
|
||||
-- print(itemsBlock)
|
||||
ItemsReceived = itemsBlock
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
function difference(a, b)
|
||||
local aa = {}
|
||||
for k,v in pairs(a) do aa[v]=true end
|
||||
@@ -93,6 +89,7 @@ function generateLocationsChecked()
|
||||
events = uRange(EventFlagAddress, 0x140)
|
||||
missables = uRange(MissableAddress, 0x20)
|
||||
hiddenitems = uRange(HiddenItemsAddress, 0x0E)
|
||||
dexsanity = uRange(DexSanityAddress, 19)
|
||||
rod = u8(RodAddress)
|
||||
|
||||
data = {}
|
||||
@@ -102,16 +99,12 @@ function generateLocationsChecked()
|
||||
table.foreach(hiddenitems, function(k, v) table.insert(data, v) end)
|
||||
table.insert(data, rod)
|
||||
|
||||
if compat > 1 then
|
||||
table.foreach(dexsanity, function(k, v) table.insert(data, v) end)
|
||||
end
|
||||
return data
|
||||
end
|
||||
function generateSerialData()
|
||||
memDomain.wram()
|
||||
status = u8(0x1A73)
|
||||
if status == 0 then
|
||||
return nil
|
||||
end
|
||||
return uRange(0x1A76, u8(0x1A74))
|
||||
end
|
||||
|
||||
local function arrayEqual(a1, a2)
|
||||
if #a1 ~= #a2 then
|
||||
return false
|
||||
@@ -135,7 +128,6 @@ function receive()
|
||||
curstate = STATE_UNINITIALIZED
|
||||
return
|
||||
elseif e == 'timeout' then
|
||||
print("timeout")
|
||||
return
|
||||
elseif e ~= nil then
|
||||
print(e)
|
||||
@@ -143,7 +135,15 @@ function receive()
|
||||
return
|
||||
end
|
||||
if l ~= nil then
|
||||
processBlock(json.decode(l))
|
||||
block = json.decode(l)
|
||||
if block ~= nil then
|
||||
local itemsBlock = block["items"]
|
||||
if itemsBlock ~= nil then
|
||||
ItemsReceived = itemsBlock
|
||||
end
|
||||
deathlink_rec = block["deathlink"]
|
||||
|
||||
end
|
||||
end
|
||||
-- Determine Message to send back
|
||||
memDomain.rom()
|
||||
@@ -157,16 +157,32 @@ function receive()
|
||||
playerName = newPlayerName
|
||||
seedName = newSeedName
|
||||
local retTable = {}
|
||||
retTable["scriptVersion"] = SCRIPT_VERSION
|
||||
|
||||
if compat == nil then
|
||||
compat = u8(ClientCompatibilityAddress)
|
||||
if compat < 2 then
|
||||
InGameAddress = 0x1A71
|
||||
end
|
||||
end
|
||||
|
||||
retTable["clientCompatibilityVersion"] = compat
|
||||
retTable["playerName"] = playerName
|
||||
retTable["seedName"] = seedName
|
||||
memDomain.wram()
|
||||
if u8(InGame) == 0xAC then
|
||||
|
||||
in_game = u8(InGameAddress)
|
||||
if in_game == 0x2A or in_game == 0xAC then
|
||||
retTable["locations"] = generateLocationsChecked()
|
||||
serialData = generateSerialData()
|
||||
if serialData ~= nil then
|
||||
retTable["serial"] = serialData
|
||||
end
|
||||
elseif in_game ~= 0 then
|
||||
print("Game may have crashed")
|
||||
curstate = STATE_UNINITIALIZED
|
||||
return
|
||||
end
|
||||
|
||||
retTable["deathLink"] = deathlink_send
|
||||
deathlink_send = false
|
||||
|
||||
msg = json.encode(retTable).."\n"
|
||||
local ret, error = gbSocket:send(msg)
|
||||
if ret == nil then
|
||||
@@ -195,10 +211,23 @@ function main()
|
||||
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
|
||||
if (frame % 5 == 0) then
|
||||
receive()
|
||||
if u8(InGame) == 0xAC and u8(APItemAddress) == 0x00 then
|
||||
ItemIndex = u16(APIndex)
|
||||
if ItemsReceived[ItemIndex + 1] ~= nil then
|
||||
wU8(APItemAddress, ItemsReceived[ItemIndex + 1] - 172000000)
|
||||
in_game = u8(InGameAddress)
|
||||
if in_game == 0x2A or in_game == 0xAC then
|
||||
if u8(APItemAddress) == 0x00 then
|
||||
ItemIndex = u16(APIndex)
|
||||
if deathlink_rec == true then
|
||||
wU8(APDeathLinkAddress, 1)
|
||||
elseif u8(APDeathLinkAddress) == 3 then
|
||||
wU8(APDeathLinkAddress, 0)
|
||||
deathlink_send = true
|
||||
end
|
||||
if ItemsReceived[ItemIndex + 1] ~= nil then
|
||||
item_id = ItemsReceived[ItemIndex + 1] - 172000000
|
||||
if item_id > 255 then
|
||||
item_id = item_id - 256
|
||||
end
|
||||
wU8(APItemAddress, item_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -212,7 +241,6 @@ function main()
|
||||
print("Attempting to connect")
|
||||
local client, timeout = server:accept()
|
||||
if timeout == nil then
|
||||
-- print('Initial Connection Made')
|
||||
curstate = STATE_INITIAL_CONNECTION_MADE
|
||||
gbSocket = client
|
||||
gbSocket:settimeout(0)
|
||||
|
||||
702
data/lua/TLoZ/TheLegendOfZeldaConnector.lua
Normal file
702
data/lua/TLoZ/TheLegendOfZeldaConnector.lua
Normal file
@@ -0,0 +1,702 @@
|
||||
--Shamelessly based off the FF1 lua
|
||||
|
||||
local socket = require("socket")
|
||||
local json = require('json')
|
||||
local math = require('math')
|
||||
|
||||
local STATE_OK = "Ok"
|
||||
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
|
||||
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
|
||||
local STATE_UNINITIALIZED = "Uninitialized"
|
||||
|
||||
local itemMessages = {}
|
||||
local consumableStacks = nil
|
||||
local prevstate = ""
|
||||
local curstate = STATE_UNINITIALIZED
|
||||
local zeldaSocket = nil
|
||||
local frame = 0
|
||||
local gameMode = 0
|
||||
|
||||
local cave_index
|
||||
local triforce_byte
|
||||
local game_state
|
||||
|
||||
local u8 = nil
|
||||
local wU8 = nil
|
||||
local isNesHawk = false
|
||||
|
||||
local shopsChecked = {}
|
||||
local shopSlotLeft = 0x0628
|
||||
local shopSlotMiddle = 0x0629
|
||||
local shopSlotRight = 0x062A
|
||||
|
||||
--N.B.: you won't find these in a RAM map. They're flag values that the base patch derives from the cave ID.
|
||||
local blueRingShopBit = 0x40
|
||||
local potionShopBit = 0x02
|
||||
local arrowShopBit = 0x08
|
||||
local candleShopBit = 0x10
|
||||
local shieldShopBit = 0x20
|
||||
local takeAnyCaveBit = 0x01
|
||||
|
||||
|
||||
local sword = 0x0657
|
||||
local bombs = 0x0658
|
||||
local maxBombs = 0x067C
|
||||
local keys = 0x066E
|
||||
local arrow = 0x0659
|
||||
local bow = 0x065A
|
||||
local candle = 0x065B
|
||||
local recorder = 0x065C
|
||||
local food = 0x065D
|
||||
local waterOfLife = 0x065E
|
||||
local magicalRod = 0x065F
|
||||
local raft = 0x0660
|
||||
local bookOfMagic = 0x0661
|
||||
local ring = 0x0662
|
||||
local stepladder = 0x0663
|
||||
local magicalKey = 0x0664
|
||||
local powerBracelet = 0x0665
|
||||
local letter = 0x0666
|
||||
local clockItem = 0x066C
|
||||
local heartContainers = 0x066F
|
||||
local partialHearts = 0x0670
|
||||
local triforceFragments = 0x0671
|
||||
local boomerang = 0x0674
|
||||
local magicalBoomerang = 0x0675
|
||||
local magicalShield = 0x0676
|
||||
local rupeesToAdd = 0x067D
|
||||
local rupeesToSubtract = 0x067E
|
||||
local itemsObtained = 0x0677
|
||||
local takeAnyCavesChecked = 0x0678
|
||||
local localTriforce = 0x0679
|
||||
local bonusItemsObtained = 0x067A
|
||||
|
||||
itemAPids = {
|
||||
["Boomerang"] = 7100,
|
||||
["Bow"] = 7101,
|
||||
["Magical Boomerang"] = 7102,
|
||||
["Raft"] = 7103,
|
||||
["Stepladder"] = 7104,
|
||||
["Recorder"] = 7105,
|
||||
["Magical Rod"] = 7106,
|
||||
["Red Candle"] = 7107,
|
||||
["Book of Magic"] = 7108,
|
||||
["Magical Key"] = 7109,
|
||||
["Red Ring"] = 7110,
|
||||
["Silver Arrow"] = 7111,
|
||||
["Sword"] = 7112,
|
||||
["White Sword"] = 7113,
|
||||
["Magical Sword"] = 7114,
|
||||
["Heart Container"] = 7115,
|
||||
["Letter"] = 7116,
|
||||
["Magical Shield"] = 7117,
|
||||
["Candle"] = 7118,
|
||||
["Arrow"] = 7119,
|
||||
["Food"] = 7120,
|
||||
["Water of Life (Blue)"] = 7121,
|
||||
["Water of Life (Red)"] = 7122,
|
||||
["Blue Ring"] = 7123,
|
||||
["Triforce Fragment"] = 7124,
|
||||
["Power Bracelet"] = 7125,
|
||||
["Small Key"] = 7126,
|
||||
["Bomb"] = 7127,
|
||||
["Recovery Heart"] = 7128,
|
||||
["Five Rupees"] = 7129,
|
||||
["Rupee"] = 7130,
|
||||
["Clock"] = 7131,
|
||||
["Fairy"] = 7132
|
||||
}
|
||||
|
||||
itemCodes = {
|
||||
["Boomerang"] = 0x1D,
|
||||
["Bow"] = 0x0A,
|
||||
["Magical Boomerang"] = 0x1E,
|
||||
["Raft"] = 0x0C,
|
||||
["Stepladder"] = 0x0D,
|
||||
["Recorder"] = 0x05,
|
||||
["Magical Rod"] = 0x10,
|
||||
["Red Candle"] = 0x07,
|
||||
["Book of Magic"] = 0x11,
|
||||
["Magical Key"] = 0x0B,
|
||||
["Red Ring"] = 0x13,
|
||||
["Silver Arrow"] = 0x09,
|
||||
["Sword"] = 0x01,
|
||||
["White Sword"] = 0x02,
|
||||
["Magical Sword"] = 0x03,
|
||||
["Heart Container"] = 0x1A,
|
||||
["Letter"] = 0x15,
|
||||
["Magical Shield"] = 0x1C,
|
||||
["Candle"] = 0x06,
|
||||
["Arrow"] = 0x08,
|
||||
["Food"] = 0x04,
|
||||
["Water of Life (Blue)"] = 0x1F,
|
||||
["Water of Life (Red)"] = 0x20,
|
||||
["Blue Ring"] = 0x12,
|
||||
["Triforce Fragment"] = 0x1B,
|
||||
["Power Bracelet"] = 0x14,
|
||||
["Small Key"] = 0x19,
|
||||
["Bomb"] = 0x00,
|
||||
["Recovery Heart"] = 0x22,
|
||||
["Five Rupees"] = 0x0F,
|
||||
["Rupee"] = 0x18,
|
||||
["Clock"] = 0x21,
|
||||
["Fairy"] = 0x23
|
||||
}
|
||||
|
||||
|
||||
--Sets correct memory access functions based on whether NesHawk or QuickNES is loaded
|
||||
local function defineMemoryFunctions()
|
||||
local memDomain = {}
|
||||
local domains = memory.getmemorydomainlist()
|
||||
if domains[1] == "System Bus" then
|
||||
--NesHawk
|
||||
isNesHawk = true
|
||||
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
|
||||
memDomain["ram"] = function() memory.usememorydomain("RAM") end
|
||||
memDomain["saveram"] = function() memory.usememorydomain("Battery RAM") end
|
||||
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
|
||||
elseif domains[1] == "WRAM" then
|
||||
--QuickNES
|
||||
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
|
||||
memDomain["ram"] = function() memory.usememorydomain("RAM") end
|
||||
memDomain["saveram"] = function() memory.usememorydomain("WRAM") end
|
||||
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
|
||||
end
|
||||
return memDomain
|
||||
end
|
||||
|
||||
local memDomain = defineMemoryFunctions()
|
||||
u8 = memory.read_u8
|
||||
wU8 = memory.write_u8
|
||||
uRange = memory.readbyterange
|
||||
|
||||
itemIDNames = {}
|
||||
|
||||
for key, value in pairs(itemAPids) do
|
||||
itemIDNames[value] = key
|
||||
end
|
||||
|
||||
|
||||
|
||||
local function determineItem(array)
|
||||
memdomain.ram()
|
||||
currentItemsObtained = u8(itemsObtained)
|
||||
|
||||
end
|
||||
|
||||
local function gotSword()
|
||||
local currentSword = u8(sword)
|
||||
wU8(sword, math.max(currentSword, 1))
|
||||
end
|
||||
|
||||
local function gotWhiteSword()
|
||||
local currentSword = u8(sword)
|
||||
wU8(sword, math.max(currentSword, 2))
|
||||
end
|
||||
|
||||
local function gotMagicalSword()
|
||||
wU8(sword, 3)
|
||||
end
|
||||
|
||||
local function gotBomb()
|
||||
local currentBombs = u8(bombs)
|
||||
local currentMaxBombs = u8(maxBombs)
|
||||
wU8(bombs, math.min(currentBombs + 4, currentMaxBombs))
|
||||
wU8(0x505, 0x29) -- Fake bomb to show item get.
|
||||
end
|
||||
|
||||
local function gotArrow()
|
||||
local currentArrow = u8(arrow)
|
||||
wU8(arrow, math.max(currentArrow, 1))
|
||||
end
|
||||
|
||||
local function gotSilverArrow()
|
||||
wU8(arrow, 2)
|
||||
end
|
||||
|
||||
local function gotBow()
|
||||
wU8(bow, 1)
|
||||
end
|
||||
|
||||
local function gotCandle()
|
||||
local currentCandle = u8(candle)
|
||||
wU8(candle, math.max(currentCandle, 1))
|
||||
end
|
||||
|
||||
local function gotRedCandle()
|
||||
wU8(candle, 2)
|
||||
end
|
||||
|
||||
local function gotRecorder()
|
||||
wU8(recorder, 1)
|
||||
end
|
||||
|
||||
local function gotFood()
|
||||
wU8(food, 1)
|
||||
end
|
||||
|
||||
local function gotWaterOfLifeBlue()
|
||||
local currentWaterOfLife = u8(waterOfLife)
|
||||
wU8(waterOfLife, math.max(currentWaterOfLife, 1))
|
||||
end
|
||||
|
||||
local function gotWaterOfLifeRed()
|
||||
wU8(waterOfLife, 2)
|
||||
end
|
||||
|
||||
local function gotMagicalRod()
|
||||
wU8(magicalRod, 1)
|
||||
end
|
||||
|
||||
local function gotBookOfMagic()
|
||||
wU8(bookOfMagic, 1)
|
||||
end
|
||||
|
||||
local function gotRaft()
|
||||
wU8(raft, 1)
|
||||
end
|
||||
|
||||
local function gotBlueRing()
|
||||
local currentRing = u8(ring)
|
||||
wU8(ring, math.max(currentRing, 1))
|
||||
memDomain.saveram()
|
||||
local currentTunicColor = u8(0x0B92)
|
||||
if currentTunicColor == 0x29 then
|
||||
wU8(0x0B92, 0x32)
|
||||
wU8(0x0804, 0x32)
|
||||
end
|
||||
end
|
||||
|
||||
local function gotRedRing()
|
||||
wU8(ring, 2)
|
||||
memDomain.saveram()
|
||||
wU8(0x0B92, 0x16)
|
||||
wU8(0x0804, 0x16)
|
||||
end
|
||||
|
||||
local function gotStepladder()
|
||||
wU8(stepladder, 1)
|
||||
end
|
||||
|
||||
local function gotMagicalKey()
|
||||
wU8(magicalKey, 1)
|
||||
end
|
||||
|
||||
local function gotPowerBracelet()
|
||||
wU8(powerBracelet, 1)
|
||||
end
|
||||
|
||||
local function gotLetter()
|
||||
wU8(letter, 1)
|
||||
end
|
||||
|
||||
local function gotHeartContainer()
|
||||
local currentHeartContainers = bit.rshift(bit.band(u8(heartContainers), 0xF0), 4)
|
||||
if currentHeartContainers < 16 then
|
||||
currentHeartContainers = math.min(currentHeartContainers + 1, 16)
|
||||
local currentHearts = bit.band(u8(heartContainers), 0x0F) + 1
|
||||
wU8(heartContainers, bit.lshift(currentHeartContainers, 4) + currentHearts)
|
||||
end
|
||||
end
|
||||
|
||||
local function gotTriforceFragment()
|
||||
local triforceByte = 0xFF
|
||||
local newTriforceCount = u8(localTriforce) + 1
|
||||
wU8(localTriforce, newTriforceCount)
|
||||
end
|
||||
|
||||
local function gotBoomerang()
|
||||
wU8(boomerang, 1)
|
||||
end
|
||||
|
||||
local function gotMagicalBoomerang()
|
||||
wU8(magicalBoomerang, 1)
|
||||
end
|
||||
|
||||
local function gotMagicalShield()
|
||||
wU8(magicalShield, 1)
|
||||
end
|
||||
|
||||
local function gotRecoveryHeart()
|
||||
local currentHearts = bit.band(u8(heartContainers), 0x0F)
|
||||
local currentHeartContainers = bit.rshift(bit.band(u8(heartContainers), 0xF0), 4)
|
||||
if currentHearts < currentHeartContainers then
|
||||
currentHearts = currentHearts + 1
|
||||
else
|
||||
wU8(partialHearts, 0xFF)
|
||||
end
|
||||
currentHearts = bit.bor(bit.band(u8(heartContainers), 0xF0), currentHearts)
|
||||
wU8(heartContainers, currentHearts)
|
||||
end
|
||||
|
||||
local function gotFairy()
|
||||
local currentHearts = bit.band(u8(heartContainers), 0x0F)
|
||||
local currentHeartContainers = bit.rshift(bit.band(u8(heartContainers), 0xF0), 4)
|
||||
if currentHearts < currentHeartContainers then
|
||||
currentHearts = currentHearts + 3
|
||||
if currentHearts > currentHeartContainers then
|
||||
currentHearts = currentHeartContainers
|
||||
wU8(partialHearts, 0xFF)
|
||||
end
|
||||
else
|
||||
wU8(partialHearts, 0xFF)
|
||||
end
|
||||
currentHearts = bit.bor(bit.band(u8(heartContainers), 0xF0), currentHearts)
|
||||
wU8(heartContainers, currentHearts)
|
||||
end
|
||||
|
||||
local function gotClock()
|
||||
wU8(clockItem, 1)
|
||||
end
|
||||
|
||||
local function gotFiveRupees()
|
||||
local currentRupeesToAdd = u8(rupeesToAdd)
|
||||
wU8(rupeesToAdd, math.min(currentRupeesToAdd + 5, 255))
|
||||
end
|
||||
|
||||
local function gotSmallKey()
|
||||
wU8(keys, math.min(u8(keys) + 1, 9))
|
||||
end
|
||||
|
||||
local function gotItem(item)
|
||||
--Write itemCode to itemToLift
|
||||
--Write 128 to itemLiftTimer
|
||||
--Write 4 to sound effect queue
|
||||
itemName = itemIDNames[item]
|
||||
itemCode = itemCodes[itemName]
|
||||
wU8(0x505, itemCode)
|
||||
wU8(0x506, 128)
|
||||
wU8(0x602, 4)
|
||||
numberObtained = u8(itemsObtained) + 1
|
||||
wU8(itemsObtained, numberObtained)
|
||||
if itemName == "Boomerang" then gotBoomerang() end
|
||||
if itemName == "Bow" then gotBow() end
|
||||
if itemName == "Magical Boomerang" then gotMagicalBoomerang() end
|
||||
if itemName == "Raft" then gotRaft() end
|
||||
if itemName == "Stepladder" then gotStepladder() end
|
||||
if itemName == "Recorder" then gotRecorder() end
|
||||
if itemName == "Magical Rod" then gotMagicalRod() end
|
||||
if itemName == "Red Candle" then gotRedCandle() end
|
||||
if itemName == "Book of Magic" then gotBookOfMagic() end
|
||||
if itemName == "Magical Key" then gotMagicalKey() end
|
||||
if itemName == "Red Ring" then gotRedRing() end
|
||||
if itemName == "Silver Arrow" then gotSilverArrow() end
|
||||
if itemName == "Sword" then gotSword() end
|
||||
if itemName == "White Sword" then gotWhiteSword() end
|
||||
if itemName == "Magical Sword" then gotMagicalSword() end
|
||||
if itemName == "Heart Container" then gotHeartContainer() end
|
||||
if itemName == "Letter" then gotLetter() end
|
||||
if itemName == "Magical Shield" then gotMagicalShield() end
|
||||
if itemName == "Candle" then gotCandle() end
|
||||
if itemName == "Arrow" then gotArrow() end
|
||||
if itemName == "Food" then gotFood() end
|
||||
if itemName == "Water of Life (Blue)" then gotWaterOfLifeBlue() end
|
||||
if itemName == "Water of Life (Red)" then gotWaterOfLifeRed() end
|
||||
if itemName == "Blue Ring" then gotBlueRing() end
|
||||
if itemName == "Triforce Fragment" then gotTriforceFragment() end
|
||||
if itemName == "Power Bracelet" then gotPowerBracelet() end
|
||||
if itemName == "Small Key" then gotSmallKey() end
|
||||
if itemName == "Bomb" then gotBomb() end
|
||||
if itemName == "Recovery Heart" then gotRecoveryHeart() end
|
||||
if itemName == "Five Rupees" then gotFiveRupees() end
|
||||
if itemName == "Fairy" then gotFairy() end
|
||||
if itemName == "Clock" then gotClock() end
|
||||
end
|
||||
|
||||
|
||||
local function StateOKForMainLoop()
|
||||
memDomain.ram()
|
||||
local gameMode = u8(0x12)
|
||||
return gameMode == 5
|
||||
end
|
||||
|
||||
local function checkCaveItemObtained()
|
||||
memDomain.ram()
|
||||
local returnTable = {}
|
||||
returnTable["slot1"] = u8(shopSlotLeft)
|
||||
returnTable["slot2"] = u8(shopSlotMiddle)
|
||||
returnTable["slot3"] = u8(shopSlotRight)
|
||||
returnTable["takeAnys"] = u8(takeAnyCavesChecked)
|
||||
return returnTable
|
||||
end
|
||||
|
||||
function table.empty (self)
|
||||
for _, _ in pairs(self) do
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function slice (tbl, s, e)
|
||||
local pos, new = 1, {}
|
||||
for i = s + 1, e do
|
||||
new[pos] = tbl[i]
|
||||
pos = pos + 1
|
||||
end
|
||||
return new
|
||||
end
|
||||
|
||||
local bizhawk_version = client.getversion()
|
||||
local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_version:sub(1,3)=="2.4") or (bizhawk_version:sub(1,3)=="2.5")
|
||||
local is26To28 = (bizhawk_version:sub(1,3)=="2.6") or (bizhawk_version:sub(1,3)=="2.7") or (bizhawk_version:sub(1,3)=="2.8")
|
||||
|
||||
local function getMaxMessageLength()
|
||||
if is23Or24Or25 then
|
||||
return client.screenwidth()/11
|
||||
elseif is26To28 then
|
||||
return client.screenwidth()/12
|
||||
end
|
||||
end
|
||||
|
||||
local function drawText(x, y, message, color)
|
||||
if is23Or24Or25 then
|
||||
gui.addmessage(message)
|
||||
elseif is26To28 then
|
||||
gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", "middle", "bottom", nil, "client")
|
||||
end
|
||||
end
|
||||
|
||||
local function clearScreen()
|
||||
if is23Or24Or25 then
|
||||
return
|
||||
elseif is26To28 then
|
||||
drawText(0, 0, "", "black")
|
||||
end
|
||||
end
|
||||
|
||||
local function drawMessages()
|
||||
if table.empty(itemMessages) then
|
||||
clearScreen()
|
||||
return
|
||||
end
|
||||
local y = 10
|
||||
found = false
|
||||
maxMessageLength = getMaxMessageLength()
|
||||
for k, v in pairs(itemMessages) do
|
||||
if v["TTL"] > 0 then
|
||||
message = v["message"]
|
||||
while true do
|
||||
drawText(5, y, message:sub(1, maxMessageLength), v["color"])
|
||||
y = y + 16
|
||||
|
||||
message = message:sub(maxMessageLength + 1, message:len())
|
||||
if message:len() == 0 then
|
||||
break
|
||||
end
|
||||
end
|
||||
newTTL = 0
|
||||
if is26To28 then
|
||||
newTTL = itemMessages[k]["TTL"] - 1
|
||||
end
|
||||
itemMessages[k]["TTL"] = newTTL
|
||||
found = true
|
||||
end
|
||||
end
|
||||
if found == false then
|
||||
clearScreen()
|
||||
end
|
||||
end
|
||||
|
||||
function generateOverworldLocationChecked()
|
||||
memDomain.ram()
|
||||
data = uRange(0x067E, 0x81)
|
||||
data[0] = nil
|
||||
return data
|
||||
end
|
||||
|
||||
function getHCLocation()
|
||||
memDomain.rom()
|
||||
data = u8(0x1789A)
|
||||
return data
|
||||
end
|
||||
|
||||
function getPBLocation()
|
||||
memDomain.rom()
|
||||
data = u8(0x10CB2)
|
||||
return data
|
||||
end
|
||||
|
||||
function generateUnderworld16LocationChecked()
|
||||
memDomain.ram()
|
||||
data = uRange(0x06FE, 0x81)
|
||||
data[0] = nil
|
||||
return data
|
||||
end
|
||||
|
||||
function generateUnderworld79LocationChecked()
|
||||
memDomain.ram()
|
||||
data = uRange(0x077E, 0x81)
|
||||
data[0] = nil
|
||||
return data
|
||||
end
|
||||
|
||||
function updateTriforceFragments()
|
||||
memDomain.ram()
|
||||
local triforceByte = 0xFF
|
||||
totalTriforceCount = u8(localTriforce)
|
||||
local currentPieces = bit.rshift(triforceByte, 8 - math.min(8, totalTriforceCount))
|
||||
wU8(triforceFragments, currentPieces)
|
||||
end
|
||||
|
||||
function processBlock(block)
|
||||
if block ~= nil then
|
||||
local msgBlock = block['messages']
|
||||
if msgBlock ~= nil then
|
||||
for i, v in pairs(msgBlock) do
|
||||
if itemMessages[i] == nil then
|
||||
local msg = {TTL=450, message=v, color=0xFFFF0000}
|
||||
itemMessages[i] = msg
|
||||
end
|
||||
end
|
||||
end
|
||||
local bonusItems = block["bonusItems"]
|
||||
if bonusItems ~= nil and isInGame then
|
||||
for i, item in ipairs(bonusItems) do
|
||||
memDomain.ram()
|
||||
if i > u8(bonusItemsObtained) then
|
||||
if u8(0x505) == 0 then
|
||||
gotItem(item)
|
||||
wU8(itemsObtained, u8(itemsObtained) - 1)
|
||||
wU8(bonusItemsObtained, u8(bonusItemsObtained) + 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local itemsBlock = block["items"]
|
||||
memDomain.saveram()
|
||||
isInGame = StateOKForMainLoop()
|
||||
updateTriforceFragments()
|
||||
if itemsBlock ~= nil and isInGame then
|
||||
memDomain.ram()
|
||||
--get item from item code
|
||||
--get function from item
|
||||
--do function
|
||||
for i, item in ipairs(itemsBlock) do
|
||||
memDomain.ram()
|
||||
if u8(0x505) == 0 then
|
||||
if i > u8(itemsObtained) then
|
||||
gotItem(item)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local shopsBlock = block["shops"]
|
||||
if shopsBlock ~= nil then
|
||||
wU8(shopSlotLeft, bit.bor(u8(shopSlotLeft), shopsBlock["left"]))
|
||||
wU8(shopSlotMiddle, bit.bor(u8(shopSlotMiddle), shopsBlock["middle"]))
|
||||
wU8(shopSlotRight, bit.bor(u8(shopSlotRight), shopsBlock["right"]))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function difference(a, b)
|
||||
local aa = {}
|
||||
for k,v in pairs(a) do aa[v]=true end
|
||||
for k,v in pairs(b) do aa[v]=nil end
|
||||
local ret = {}
|
||||
local n = 0
|
||||
for k,v in pairs(a) do
|
||||
if aa[v] then n=n+1 ret[n]=v end
|
||||
end
|
||||
return ret
|
||||
end
|
||||
|
||||
function receive()
|
||||
l, e = zeldaSocket:receive()
|
||||
if e == 'closed' then
|
||||
if curstate == STATE_OK then
|
||||
print("Connection closed")
|
||||
end
|
||||
curstate = STATE_UNINITIALIZED
|
||||
return
|
||||
elseif e == 'timeout' then
|
||||
print("timeout")
|
||||
return
|
||||
elseif e ~= nil then
|
||||
print(e)
|
||||
curstate = STATE_UNINITIALIZED
|
||||
return
|
||||
end
|
||||
processBlock(json.decode(l))
|
||||
|
||||
-- Determine Message to send back
|
||||
memDomain.rom()
|
||||
local playerName = uRange(0x1F, 0x10)
|
||||
playerName[0] = nil
|
||||
local retTable = {}
|
||||
retTable["playerName"] = playerName
|
||||
if StateOKForMainLoop() then
|
||||
retTable["overworld"] = generateOverworldLocationChecked()
|
||||
retTable["underworld1"] = generateUnderworld16LocationChecked()
|
||||
retTable["underworld2"] = generateUnderworld79LocationChecked()
|
||||
end
|
||||
retTable["caves"] = checkCaveItemObtained()
|
||||
memDomain.ram()
|
||||
if gameMode ~= 19 then
|
||||
gameMode = u8(0x12)
|
||||
end
|
||||
retTable["gameMode"] = gameMode
|
||||
retTable["overworldHC"] = getHCLocation()
|
||||
retTable["overworldPB"] = getPBLocation()
|
||||
retTable["itemsObtained"] = u8(itemsObtained)
|
||||
msg = json.encode(retTable).."\n"
|
||||
local ret, error = zeldaSocket:send(msg)
|
||||
if ret == nil then
|
||||
print(error)
|
||||
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
|
||||
curstate = STATE_TENTATIVELY_CONNECTED
|
||||
elseif curstate == STATE_TENTATIVELY_CONNECTED then
|
||||
print("Connected!")
|
||||
itemMessages["(0,0)"] = {TTL=240, message="Connected", color="green"}
|
||||
curstate = STATE_OK
|
||||
end
|
||||
end
|
||||
|
||||
function main()
|
||||
if (is23Or24Or25 or is26To28) == false then
|
||||
print("Must use a version of bizhawk 2.3.1 or higher")
|
||||
return
|
||||
end
|
||||
server, error = socket.bind('localhost', 52980)
|
||||
|
||||
while true do
|
||||
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
|
||||
frame = frame + 1
|
||||
drawMessages()
|
||||
if not (curstate == prevstate) then
|
||||
-- console.log("Current state: "..curstate)
|
||||
prevstate = curstate
|
||||
end
|
||||
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
|
||||
if (frame % 60 == 0) then
|
||||
gui.drawEllipse(248, 9, 6, 6, "Black", "Blue")
|
||||
receive()
|
||||
else
|
||||
gui.drawEllipse(248, 9, 6, 6, "Black", "Green")
|
||||
end
|
||||
elseif (curstate == STATE_UNINITIALIZED) then
|
||||
gui.drawEllipse(248, 9, 6, 6, "Black", "White")
|
||||
if (frame % 60 == 0) then
|
||||
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
|
||||
|
||||
drawText(5, 8, "Waiting for client", 0xFFFF0000)
|
||||
drawText(5, 32, "Please start Zelda1Client.exe", 0xFFFF0000)
|
||||
|
||||
-- Advance so the messages are drawn
|
||||
emu.frameadvance()
|
||||
server:settimeout(2)
|
||||
print("Attempting to connect")
|
||||
local client, timeout = server:accept()
|
||||
if timeout == nil then
|
||||
-- print('Initial Connection Made')
|
||||
curstate = STATE_INITIAL_CONNECTION_MADE
|
||||
zeldaSocket = client
|
||||
zeldaSocket:settimeout(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
emu.frameadvance()
|
||||
end
|
||||
end
|
||||
|
||||
main()
|
||||
BIN
data/lua/TLoZ/core.dll
Normal file
BIN
data/lua/TLoZ/core.dll
Normal file
Binary file not shown.
380
data/lua/TLoZ/json.lua
Normal file
380
data/lua/TLoZ/json.lua
Normal file
@@ -0,0 +1,380 @@
|
||||
--
|
||||
-- json.lua
|
||||
--
|
||||
-- Copyright (c) 2015 rxi
|
||||
--
|
||||
-- This library is free software; you can redistribute it and/or modify it
|
||||
-- under the terms of the MIT license. See LICENSE for details.
|
||||
--
|
||||
|
||||
local json = { _version = "0.1.0" }
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
-- Encode
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
local encode
|
||||
|
||||
local escape_char_map = {
|
||||
[ "\\" ] = "\\\\",
|
||||
[ "\"" ] = "\\\"",
|
||||
[ "\b" ] = "\\b",
|
||||
[ "\f" ] = "\\f",
|
||||
[ "\n" ] = "\\n",
|
||||
[ "\r" ] = "\\r",
|
||||
[ "\t" ] = "\\t",
|
||||
}
|
||||
|
||||
local escape_char_map_inv = { [ "\\/" ] = "/" }
|
||||
for k, v in pairs(escape_char_map) do
|
||||
escape_char_map_inv[v] = k
|
||||
end
|
||||
|
||||
|
||||
local function escape_char(c)
|
||||
return escape_char_map[c] or string.format("\\u%04x", c:byte())
|
||||
end
|
||||
|
||||
|
||||
local function encode_nil(val)
|
||||
return "null"
|
||||
end
|
||||
|
||||
|
||||
local function encode_table(val, stack)
|
||||
local res = {}
|
||||
stack = stack or {}
|
||||
|
||||
-- Circular reference?
|
||||
if stack[val] then error("circular reference") end
|
||||
|
||||
stack[val] = true
|
||||
|
||||
if val[1] ~= nil or next(val) == nil then
|
||||
-- Treat as array -- check keys are valid and it is not sparse
|
||||
local n = 0
|
||||
for k in pairs(val) do
|
||||
if type(k) ~= "number" then
|
||||
error("invalid table: mixed or invalid key types")
|
||||
end
|
||||
n = n + 1
|
||||
end
|
||||
if n ~= #val then
|
||||
error("invalid table: sparse array")
|
||||
end
|
||||
-- Encode
|
||||
for i, v in ipairs(val) do
|
||||
table.insert(res, encode(v, stack))
|
||||
end
|
||||
stack[val] = nil
|
||||
return "[" .. table.concat(res, ",") .. "]"
|
||||
|
||||
else
|
||||
-- Treat as an object
|
||||
for k, v in pairs(val) do
|
||||
if type(k) ~= "string" then
|
||||
error("invalid table: mixed or invalid key types")
|
||||
end
|
||||
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
|
||||
end
|
||||
stack[val] = nil
|
||||
return "{" .. table.concat(res, ",") .. "}"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function encode_string(val)
|
||||
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
|
||||
end
|
||||
|
||||
|
||||
local function encode_number(val)
|
||||
-- Check for NaN, -inf and inf
|
||||
if val ~= val or val <= -math.huge or val >= math.huge then
|
||||
error("unexpected number value '" .. tostring(val) .. "'")
|
||||
end
|
||||
return string.format("%.14g", val)
|
||||
end
|
||||
|
||||
|
||||
local type_func_map = {
|
||||
[ "nil" ] = encode_nil,
|
||||
[ "table" ] = encode_table,
|
||||
[ "string" ] = encode_string,
|
||||
[ "number" ] = encode_number,
|
||||
[ "boolean" ] = tostring,
|
||||
}
|
||||
|
||||
|
||||
encode = function(val, stack)
|
||||
local t = type(val)
|
||||
local f = type_func_map[t]
|
||||
if f then
|
||||
return f(val, stack)
|
||||
end
|
||||
error("unexpected type '" .. t .. "'")
|
||||
end
|
||||
|
||||
|
||||
function json.encode(val)
|
||||
return ( encode(val) )
|
||||
end
|
||||
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
-- Decode
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
local parse
|
||||
|
||||
local function create_set(...)
|
||||
local res = {}
|
||||
for i = 1, select("#", ...) do
|
||||
res[ select(i, ...) ] = true
|
||||
end
|
||||
return res
|
||||
end
|
||||
|
||||
local space_chars = create_set(" ", "\t", "\r", "\n")
|
||||
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
|
||||
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
|
||||
local literals = create_set("true", "false", "null")
|
||||
|
||||
local literal_map = {
|
||||
[ "true" ] = true,
|
||||
[ "false" ] = false,
|
||||
[ "null" ] = nil,
|
||||
}
|
||||
|
||||
|
||||
local function next_char(str, idx, set, negate)
|
||||
for i = idx, #str do
|
||||
if set[str:sub(i, i)] ~= negate then
|
||||
return i
|
||||
end
|
||||
end
|
||||
return #str + 1
|
||||
end
|
||||
|
||||
|
||||
local function decode_error(str, idx, msg)
|
||||
--local line_count = 1
|
||||
--local col_count = 1
|
||||
--for i = 1, idx - 1 do
|
||||
-- col_count = col_count + 1
|
||||
-- if str:sub(i, i) == "\n" then
|
||||
-- line_count = line_count + 1
|
||||
-- col_count = 1
|
||||
-- end
|
||||
-- end
|
||||
-- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) )
|
||||
end
|
||||
|
||||
|
||||
local function codepoint_to_utf8(n)
|
||||
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
|
||||
local f = math.floor
|
||||
if n <= 0x7f then
|
||||
return string.char(n)
|
||||
elseif n <= 0x7ff then
|
||||
return string.char(f(n / 64) + 192, n % 64 + 128)
|
||||
elseif n <= 0xffff then
|
||||
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
|
||||
elseif n <= 0x10ffff then
|
||||
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
|
||||
f(n % 4096 / 64) + 128, n % 64 + 128)
|
||||
end
|
||||
error( string.format("invalid unicode codepoint '%x'", n) )
|
||||
end
|
||||
|
||||
|
||||
local function parse_unicode_escape(s)
|
||||
local n1 = tonumber( s:sub(3, 6), 16 )
|
||||
local n2 = tonumber( s:sub(9, 12), 16 )
|
||||
-- Surrogate pair?
|
||||
if n2 then
|
||||
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
|
||||
else
|
||||
return codepoint_to_utf8(n1)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function parse_string(str, i)
|
||||
local has_unicode_escape = false
|
||||
local has_surrogate_escape = false
|
||||
local has_escape = false
|
||||
local last
|
||||
for j = i + 1, #str do
|
||||
local x = str:byte(j)
|
||||
|
||||
if x < 32 then
|
||||
decode_error(str, j, "control character in string")
|
||||
end
|
||||
|
||||
if last == 92 then -- "\\" (escape char)
|
||||
if x == 117 then -- "u" (unicode escape sequence)
|
||||
local hex = str:sub(j + 1, j + 5)
|
||||
if not hex:find("%x%x%x%x") then
|
||||
decode_error(str, j, "invalid unicode escape in string")
|
||||
end
|
||||
if hex:find("^[dD][89aAbB]") then
|
||||
has_surrogate_escape = true
|
||||
else
|
||||
has_unicode_escape = true
|
||||
end
|
||||
else
|
||||
local c = string.char(x)
|
||||
if not escape_chars[c] then
|
||||
decode_error(str, j, "invalid escape char '" .. c .. "' in string")
|
||||
end
|
||||
has_escape = true
|
||||
end
|
||||
last = nil
|
||||
|
||||
elseif x == 34 then -- '"' (end of string)
|
||||
local s = str:sub(i + 1, j - 1)
|
||||
if has_surrogate_escape then
|
||||
s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape)
|
||||
end
|
||||
if has_unicode_escape then
|
||||
s = s:gsub("\\u....", parse_unicode_escape)
|
||||
end
|
||||
if has_escape then
|
||||
s = s:gsub("\\.", escape_char_map_inv)
|
||||
end
|
||||
return s, j + 1
|
||||
|
||||
else
|
||||
last = x
|
||||
end
|
||||
end
|
||||
decode_error(str, i, "expected closing quote for string")
|
||||
end
|
||||
|
||||
|
||||
local function parse_number(str, i)
|
||||
local x = next_char(str, i, delim_chars)
|
||||
local s = str:sub(i, x - 1)
|
||||
local n = tonumber(s)
|
||||
if not n then
|
||||
decode_error(str, i, "invalid number '" .. s .. "'")
|
||||
end
|
||||
return n, x
|
||||
end
|
||||
|
||||
|
||||
local function parse_literal(str, i)
|
||||
local x = next_char(str, i, delim_chars)
|
||||
local word = str:sub(i, x - 1)
|
||||
if not literals[word] then
|
||||
decode_error(str, i, "invalid literal '" .. word .. "'")
|
||||
end
|
||||
return literal_map[word], x
|
||||
end
|
||||
|
||||
|
||||
local function parse_array(str, i)
|
||||
local res = {}
|
||||
local n = 1
|
||||
i = i + 1
|
||||
while 1 do
|
||||
local x
|
||||
i = next_char(str, i, space_chars, true)
|
||||
-- Empty / end of array?
|
||||
if str:sub(i, i) == "]" then
|
||||
i = i + 1
|
||||
break
|
||||
end
|
||||
-- Read token
|
||||
x, i = parse(str, i)
|
||||
res[n] = x
|
||||
n = n + 1
|
||||
-- Next token
|
||||
i = next_char(str, i, space_chars, true)
|
||||
local chr = str:sub(i, i)
|
||||
i = i + 1
|
||||
if chr == "]" then break end
|
||||
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
|
||||
end
|
||||
return res, i
|
||||
end
|
||||
|
||||
|
||||
local function parse_object(str, i)
|
||||
local res = {}
|
||||
i = i + 1
|
||||
while 1 do
|
||||
local key, val
|
||||
i = next_char(str, i, space_chars, true)
|
||||
-- Empty / end of object?
|
||||
if str:sub(i, i) == "}" then
|
||||
i = i + 1
|
||||
break
|
||||
end
|
||||
-- Read key
|
||||
if str:sub(i, i) ~= '"' then
|
||||
decode_error(str, i, "expected string for key")
|
||||
end
|
||||
key, i = parse(str, i)
|
||||
-- Read ':' delimiter
|
||||
i = next_char(str, i, space_chars, true)
|
||||
if str:sub(i, i) ~= ":" then
|
||||
decode_error(str, i, "expected ':' after key")
|
||||
end
|
||||
i = next_char(str, i + 1, space_chars, true)
|
||||
-- Read value
|
||||
val, i = parse(str, i)
|
||||
-- Set
|
||||
res[key] = val
|
||||
-- Next token
|
||||
i = next_char(str, i, space_chars, true)
|
||||
local chr = str:sub(i, i)
|
||||
i = i + 1
|
||||
if chr == "}" then break end
|
||||
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
|
||||
end
|
||||
return res, i
|
||||
end
|
||||
|
||||
|
||||
local char_func_map = {
|
||||
[ '"' ] = parse_string,
|
||||
[ "0" ] = parse_number,
|
||||
[ "1" ] = parse_number,
|
||||
[ "2" ] = parse_number,
|
||||
[ "3" ] = parse_number,
|
||||
[ "4" ] = parse_number,
|
||||
[ "5" ] = parse_number,
|
||||
[ "6" ] = parse_number,
|
||||
[ "7" ] = parse_number,
|
||||
[ "8" ] = parse_number,
|
||||
[ "9" ] = parse_number,
|
||||
[ "-" ] = parse_number,
|
||||
[ "t" ] = parse_literal,
|
||||
[ "f" ] = parse_literal,
|
||||
[ "n" ] = parse_literal,
|
||||
[ "[" ] = parse_array,
|
||||
[ "{" ] = parse_object,
|
||||
}
|
||||
|
||||
|
||||
parse = function(str, idx)
|
||||
local chr = str:sub(idx, idx)
|
||||
local f = char_func_map[chr]
|
||||
if f then
|
||||
return f(str, idx)
|
||||
end
|
||||
decode_error(str, idx, "unexpected character '" .. chr .. "'")
|
||||
end
|
||||
|
||||
|
||||
function json.decode(str)
|
||||
if type(str) ~= "string" then
|
||||
error("expected argument of type string, got " .. type(str))
|
||||
end
|
||||
return ( parse(str, next_char(str, 1, space_chars, true)) )
|
||||
end
|
||||
|
||||
|
||||
return json
|
||||
132
data/lua/TLoZ/socket.lua
Normal file
132
data/lua/TLoZ/socket.lua
Normal file
@@ -0,0 +1,132 @@
|
||||
-----------------------------------------------------------------------------
|
||||
-- LuaSocket helper module
|
||||
-- Author: Diego Nehab
|
||||
-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
-- Declare module and import dependencies
|
||||
-----------------------------------------------------------------------------
|
||||
local base = _G
|
||||
local string = require("string")
|
||||
local math = require("math")
|
||||
local socket = require("socket.core")
|
||||
module("socket")
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
-- Exported auxiliar functions
|
||||
-----------------------------------------------------------------------------
|
||||
function connect(address, port, laddress, lport)
|
||||
local sock, err = socket.tcp()
|
||||
if not sock then return nil, err end
|
||||
if laddress then
|
||||
local res, err = sock:bind(laddress, lport, -1)
|
||||
if not res then return nil, err end
|
||||
end
|
||||
local res, err = sock:connect(address, port)
|
||||
if not res then return nil, err end
|
||||
return sock
|
||||
end
|
||||
|
||||
function bind(host, port, backlog)
|
||||
local sock, err = socket.tcp()
|
||||
if not sock then return nil, err end
|
||||
sock:setoption("reuseaddr", true)
|
||||
local res, err = sock:bind(host, port)
|
||||
if not res then return nil, err end
|
||||
res, err = sock:listen(backlog)
|
||||
if not res then return nil, err end
|
||||
return sock
|
||||
end
|
||||
|
||||
try = newtry()
|
||||
|
||||
function choose(table)
|
||||
return function(name, opt1, opt2)
|
||||
if base.type(name) ~= "string" then
|
||||
name, opt1, opt2 = "default", name, opt1
|
||||
end
|
||||
local f = table[name or "nil"]
|
||||
if not f then base.error("unknown key (".. base.tostring(name) ..")", 3)
|
||||
else return f(opt1, opt2) end
|
||||
end
|
||||
end
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
-- Socket sources and sinks, conforming to LTN12
|
||||
-----------------------------------------------------------------------------
|
||||
-- create namespaces inside LuaSocket namespace
|
||||
sourcet = {}
|
||||
sinkt = {}
|
||||
|
||||
BLOCKSIZE = 2048
|
||||
|
||||
sinkt["close-when-done"] = function(sock)
|
||||
return base.setmetatable({
|
||||
getfd = function() return sock:getfd() end,
|
||||
dirty = function() return sock:dirty() end
|
||||
}, {
|
||||
__call = function(self, chunk, err)
|
||||
if not chunk then
|
||||
sock:close()
|
||||
return 1
|
||||
else return sock:send(chunk) end
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
sinkt["keep-open"] = function(sock)
|
||||
return base.setmetatable({
|
||||
getfd = function() return sock:getfd() end,
|
||||
dirty = function() return sock:dirty() end
|
||||
}, {
|
||||
__call = function(self, chunk, err)
|
||||
if chunk then return sock:send(chunk)
|
||||
else return 1 end
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
sinkt["default"] = sinkt["keep-open"]
|
||||
|
||||
sink = choose(sinkt)
|
||||
|
||||
sourcet["by-length"] = function(sock, length)
|
||||
return base.setmetatable({
|
||||
getfd = function() return sock:getfd() end,
|
||||
dirty = function() return sock:dirty() end
|
||||
}, {
|
||||
__call = function()
|
||||
if length <= 0 then return nil end
|
||||
local size = math.min(socket.BLOCKSIZE, length)
|
||||
local chunk, err = sock:receive(size)
|
||||
if err then return nil, err end
|
||||
length = length - string.len(chunk)
|
||||
return chunk
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
sourcet["until-closed"] = function(sock)
|
||||
local done
|
||||
return base.setmetatable({
|
||||
getfd = function() return sock:getfd() end,
|
||||
dirty = function() return sock:dirty() end
|
||||
}, {
|
||||
__call = function()
|
||||
if done then return nil end
|
||||
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
|
||||
if not err then return chunk
|
||||
elseif err == "closed" then
|
||||
sock:close()
|
||||
done = 1
|
||||
return partial
|
||||
else return nil, err end
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
|
||||
sourcet["default"] = sourcet["until-closed"]
|
||||
|
||||
source = choose(sourcet)
|
||||
@@ -148,7 +148,7 @@ The next step is to know what you need to make the game do now that you can modi
|
||||
- Listen for messages from the Archipelago server
|
||||
- Modify the game to display messages from the Archipelago server
|
||||
- Add interface for connecting to the Archipelago server with passwords and sessions
|
||||
- Add commands for manually rewarding, re-syncing, forfeiting, and other actions
|
||||
- Add commands for manually rewarding, re-syncing, releasing, and other actions
|
||||
|
||||
To elaborate, you need to be able to inform the server whenever you check locations, print out messages that you receive
|
||||
from the server in-game so players can read them, award items when the server tells you to, sync and re-sync when necessary,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
|
||||
Those are located in the `worlds/` folder (source) or `<insall dir>/lib/worlds/` (when installed).
|
||||
See [world api.md](world api.md) for details.
|
||||
See [world api.md](world%20api.md) for details.
|
||||
|
||||
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld`
|
||||
file into the worlds folder.
|
||||
|
||||
@@ -8,4 +8,4 @@ We conduct ourselves openly and inclusively here. Please do not contribute to an
|
||||
|
||||
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private ones, such as private messaging or emails.
|
||||
|
||||
Any incidents of abuse may be reported directly to ijwu at hmfarran@gmail.com.
|
||||
Any incidents of abuse may be reported directly to eudaimonistic at eudaimonistic42@gmail.com
|
||||
|
||||
@@ -9,7 +9,7 @@ These steps should be followed in order to establish a gameplay connection with
|
||||
5. Client sends [Connect](#Connect) packet in order to authenticate with the server.
|
||||
6. Server validates the client's packet and responds with [Connected](#Connected) or [ConnectionRefused](#ConnectionRefused).
|
||||
7. Server may send [ReceivedItems](#ReceivedItems) to the client, in the case that the client is missing items that are queued up for it.
|
||||
8. Server sends [Print](#Print) to all players to notify them of the new client connection.
|
||||
8. Server sends [PrintJSON](#PrintJSON) to all players to notify them of the new client connection.
|
||||
|
||||
In the case that the client does not authenticate properly and receives a [ConnectionRefused](#ConnectionRefused) then the server will maintain the connection and allow for follow-up [Connect](#Connect) packet.
|
||||
|
||||
@@ -54,7 +54,6 @@ These packets are are sent from the multiworld server to the client. They are no
|
||||
* [ReceivedItems](#ReceivedItems)
|
||||
* [LocationInfo](#LocationInfo)
|
||||
* [RoomUpdate](#RoomUpdate)
|
||||
* [Print](#Print)
|
||||
* [PrintJSON](#PrintJSON)
|
||||
* [DataPackage](#DataPackage)
|
||||
* [Bounced](#Bounced)
|
||||
@@ -70,23 +69,22 @@ Sent to clients when they connect to an Archipelago server.
|
||||
| version | [NetworkVersion](#NetworkVersion) | Object denoting the version of Archipelago which the server is running. |
|
||||
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` |
|
||||
| password | bool | Denoted whether a password is required to join this room.|
|
||||
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "forfeit", "collect" and "remaining". |
|
||||
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
|
||||
| hint_cost | int | The amount of points it costs to receive a hint from the server. |
|
||||
| location_check_points | int | The amount of hint points you receive per item/location check completed. ||
|
||||
| games | list\[str\] | List of games present in this multiworld. |
|
||||
| datapackage_version | int | Sum of individual games' datapackage version. Deprecated. Use `datapackage_versions` instead. |
|
||||
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. Used to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents). |
|
||||
| seed_name | str | uniquely identifying name of this generation |
|
||||
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
|
||||
|
||||
#### forfeit
|
||||
Dictates what is allowed when it comes to a player forfeiting their run. A forfeit is an action which distributes the rest of the items in a player's run to those other players awaiting them.
|
||||
#### release
|
||||
Dictates what is allowed when it comes to a player releasing their run. A release is an action which distributes the rest of the items in a player's run to those other players awaiting them.
|
||||
|
||||
* `auto`: Distributes a player's items to other players when they complete their goal.
|
||||
* `enabled`: Denotes that players may forfeit at any time in the game.
|
||||
* `enabled`: Denotes that players may release at any time in the game.
|
||||
* `auto-enabled`: Both of the above options together.
|
||||
* `disabled`: All forfeit modes disabled.
|
||||
* `goal`: Allows for manual use of forfeit command once a player completes their goal. (Disabled until goal completion)
|
||||
* `disabled`: All release modes disabled.
|
||||
* `goal`: Allows for manual use of release command once a player completes their goal. (Disabled until goal completion)
|
||||
|
||||
#### collect
|
||||
Dictates what is allowed when it comes to a player collecting their run. A collect is an action which sends the rest of the items in a player's run.
|
||||
@@ -121,15 +119,15 @@ InvalidItemsHandling indicates a wrong value type or flag combination was sent.
|
||||
### Connected
|
||||
Sent to clients when the connection handshake is successfully completed.
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| team | int | Your team number. See [NetworkPlayer](#NetworkPlayer) for more info on team number. |
|
||||
| slot | int | Your slot number on your team. See [NetworkPlayer](#NetworkPlayer) for more info on the slot number. |
|
||||
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | List denoting other players in the multiworld, whether connected or not. |
|
||||
| missing_locations | list\[int\] | Contains ids of remaining locations that need to be checked. Useful for trackers, among other things. |
|
||||
| checked_locations | list\[int\] | Contains ids of all locations that have been checked. Useful for trackers, among other things. Location ids are in the range of ± 2<sup>53</sup>-1. |
|
||||
| slot_data | dict | Contains a json object for slot related data, differs per game. Empty if not required. |
|
||||
| slot_info | dict\[int, [NetworkSlot](#NetworkSlot)\] | maps each slot to a [NetworkSlot](#NetworkSlot) information |
|
||||
| Name | Type | Notes |
|
||||
|-------------------|------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| team | int | Your team number. See [NetworkPlayer](#NetworkPlayer) for more info on team number. |
|
||||
| slot | int | Your slot number on your team. See [NetworkPlayer](#NetworkPlayer) for more info on the slot number. |
|
||||
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | List denoting other players in the multiworld, whether connected or not. |
|
||||
| missing_locations | list\[int\] | Contains ids of remaining locations that need to be checked. Useful for trackers, among other things. |
|
||||
| checked_locations | list\[int\] | Contains ids of all locations that have been checked. Useful for trackers, among other things. Location ids are in the range of ± 2<sup>53</sup>-1. |
|
||||
| slot_data | dict\[str, any\] | Contains a json object for slot related data, differs per game. Empty if not required. Not present if slot_data in [Connect](#Connect) is false. |
|
||||
| slot_info | dict\[int, [NetworkSlot](#NetworkSlot)\] | maps each slot to a [NetworkSlot](#NetworkSlot) information |
|
||||
|
||||
### ReceivedItems
|
||||
Sent to clients when they receive an item.
|
||||
@@ -161,35 +159,44 @@ The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring:
|
||||
|
||||
All arguments for this packet are optional, only changes are sent.
|
||||
|
||||
### Print
|
||||
Sent to clients purely to display a message to the player.
|
||||
* *Deprecation warning: clients that connect with version 0.3.5 or higher will nolonger recieve Print packets, instead all messsages are send as [PrintJSON](#PrintJSON)*
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| text | str | Message to display to player. |
|
||||
|
||||
### PrintJSON
|
||||
Sent to clients purely to display a message to the player. This packet differs from [Print](#Print) in that the data being sent with this packet allows for more configurable or specific messaging.
|
||||
Sent to clients purely to display a message to the player. While various message types provide additional arguments, clients only need to evaluate the `data` argument to construct the human-readable message text. All other arguments may be ignored safely.
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| data | list\[[JSONMessagePart](#JSONMessagePart)\] | Type of this part of the message. |
|
||||
| type | str | May be present to indicate the [PrintJsonType](#PrintJsonType) of this message. |
|
||||
| receiving | int | Is present if type is Hint or ItemSend and marks the destination player's ID. |
|
||||
| item | [NetworkItem](#NetworkItem) | Is present if type is Hint or ItemSend and marks the source player id, location id, item id and item flags. |
|
||||
| found | bool | Is present if type is Hint, denotes whether the location hinted for was checked. |
|
||||
| countdown | int | Is present if type is `Countdown`, denotes the amount of seconds remaining on the countdown. |
|
||||
| Name | Type | Message Types | Contents |
|
||||
| ---- | ---- | ------------- | -------- |
|
||||
| data | list\[[JSONMessagePart](#JSONMessagePart)\] | (all) | Textual content of this message |
|
||||
| type | str | (any) | [PrintJsonType](#PrintJsonType) of this message (optional) |
|
||||
| receiving | int | ItemSend, ItemCheat, Hint | Destination player's ID |
|
||||
| item | [NetworkItem](#NetworkItem) | ItemSend, ItemCheat, Hint | Source player's ID, location ID, item ID and item flags |
|
||||
| found | bool | Hint | Whether the location hinted for was checked |
|
||||
| team | int | Join, Part, Chat, TagsChanged, Goal, Release, Collect, ItemCheat | Team of the triggering player |
|
||||
| slot | int | Join, Part, Chat, TagsChanged, Goal, Release, Collect | Slot of the triggering player |
|
||||
| message | str | Chat, ServerChat | Original chat message without sender prefix |
|
||||
| tags | list\[str\] | Join, TagsChanged | Tags of the triggering player |
|
||||
| countdown | int | Countdown | Amount of seconds remaining on the countdown |
|
||||
|
||||
##### PrintJsonType
|
||||
PrintJsonType indicates the type of [PrintJson](#PrintJson) packet, different types can be handled differently by the client and can also contain additional arguments. When receiving an unknown type the data's list\[[JSONMessagePart](#JSONMessagePart)\] should still be printed as normal.
|
||||
#### PrintJsonType
|
||||
PrintJsonType indicates the type of a [PrintJSON](#PrintJSON) packet. Different types can be handled differently by the client and can also contain additional arguments. When receiving an unknown or missing type, the `data`'s list\[[JSONMessagePart](#JSONMessagePart)\] should still be displayed to the player as normal text.
|
||||
|
||||
Currently defined types are:
|
||||
| Type | Notes |
|
||||
| ---- | ----- |
|
||||
| ItemSend | The message is in response to a player receiving an item. |
|
||||
| Hint | The message is in response to a player hinting. |
|
||||
| Countdown | The message contains information about the current server Countdown. |
|
||||
|
||||
| Type | Subject |
|
||||
| ---- | ------- |
|
||||
| ItemSend | A player received an item. |
|
||||
| ItemCheat | A player used the `!getitem` command. |
|
||||
| Hint | A player hinted. |
|
||||
| Join | A player connected. |
|
||||
| Part | A player disconnected. |
|
||||
| Chat | A player sent a chat message. |
|
||||
| ServerChat | The server broadcasted a message. |
|
||||
| Tutorial | The client has triggered a tutorial message, such as when first connecting. |
|
||||
| TagsChanged | A player changed their tags. |
|
||||
| CommandResult | Someone (usually the client) entered an `!` command. |
|
||||
| AdminCommandResult | The client entered an `!admin` command. |
|
||||
| Goal | A player reached their goal. |
|
||||
| Release | A player released the remaining items in their world. |
|
||||
| Collect | A player collected the remaining items for their world. |
|
||||
| Countdown | The current server countdown has progressed. |
|
||||
|
||||
### DataPackage
|
||||
Sent to clients to provide what is known as a 'data package' which contains information to enable a client to most easily communicate with the Archipelago server. Contents include things like location id to name mappings, among others; see [Data Package Contents](#Data-Package-Contents) for more info.
|
||||
@@ -242,11 +249,11 @@ Additional arguments added to the [Get](#Get) package that triggered this [Retri
|
||||
### SetReply
|
||||
Sent to clients in response to a [Set](#Set) package if want_reply was set to true, or if the client has registered to receive updates for a certain key using the [SetNotify](#SetNotify) package. SetReply packages are sent even if a [Set](#Set) package did not alter the value for the key.
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| key | str | The key that was updated. |
|
||||
| value | any | The new value for the key. |
|
||||
| original_value | any | The value the key had before it was updated. |
|
||||
| Name | Type | Notes |
|
||||
|----------------|------|--------------------------------------------------------------------------------------------|
|
||||
| key | str | The key that was updated. |
|
||||
| value | any | The new value for the key. |
|
||||
| original_value | any | The value the key had before it was updated. Not present on "_read" prefixed special keys. |
|
||||
|
||||
Additional arguments added to the [Set](#Set) package that triggered this [SetReply](#SetReply) will also be passed along.
|
||||
|
||||
@@ -269,15 +276,16 @@ These packets are sent purely from client to server. They are not accepted by cl
|
||||
Sent by the client to initiate a connection to an Archipelago game session.
|
||||
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| password | str | If the game session requires a password, it should be passed here. |
|
||||
| game | str | The name of the game the client is playing. Example: `A Link to the Past` |
|
||||
| name | str | The player name for this client. |
|
||||
| uuid | str | Unique identifier for player client. |
|
||||
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
|
||||
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
|
||||
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
|
||||
| Name | Type | Notes |
|
||||
|----------------|-----------------------------------|----------------------------------------------------------------------------------------------|
|
||||
| password | str | If the game session requires a password, it should be passed here. |
|
||||
| game | str | The name of the game the client is playing. Example: `A Link to the Past` |
|
||||
| name | str | The player name for this client. |
|
||||
| uuid | str | Unique identifier for player client. |
|
||||
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
|
||||
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
|
||||
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
|
||||
| slot_data | bool | If true, the Connect answer will contain slot_data |
|
||||
|
||||
#### items_handling flags
|
||||
| Value | Meaning |
|
||||
@@ -367,14 +375,23 @@ Used to request a single or multiple values from the server's data storage, see
|
||||
|
||||
Additional arguments sent in this package will also be added to the [Retrieved](#Retrieved) package it triggers.
|
||||
|
||||
Some special keys exist with specific return data, all of them have the prefix `_read_`, so `hints_{team}_{slot}` is `_read_hints_{team}_{slot}`.
|
||||
|
||||
| Name | Type | Notes |
|
||||
|-------------------------------|--------------------------|---------------------------------------------------|
|
||||
| hints_{team}_{slot} | list\[[Hint](#Hint)\] | All Hints belonging to the requested Player. |
|
||||
| slot_data_{slot} | dict\[str, any\] | slot_data belonging to the requested slot. |
|
||||
| item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. |
|
||||
|
||||
### Set
|
||||
Used to write data to the server's data storage, that data can then be shared across worlds or just saved for later. Values for keys in the data storage can be retrieved with a [Get](#Get) package, or monitored with a [SetNotify](#SetNotify) package.
|
||||
Keys that start with `_read_` cannot be set.
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ------ | ----- | ------ |
|
||||
| key | str | The key to manipulate. |
|
||||
| default | any | The default value to use in case the key has no value on the server. |
|
||||
| want_reply | bool | If true, the server will send a [SetReply](#SetReply) response back to the client. |
|
||||
| Name | Type | Notes |
|
||||
|------------|-------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|
|
||||
| key | str | The key to manipulate. Can never start with "_read". |
|
||||
| default | any | The default value to use in case the key has no value on the server. |
|
||||
| want_reply | bool | If true, the server will send a [SetReply](#SetReply) response back to the client. |
|
||||
| operations | list\[[DataStorageOperation](#DataStorageOperation)\] | Operations to apply to the value, multiple operations can be present and they will be executed in order of appearance. |
|
||||
|
||||
Additional arguments sent in this package will also be added to the [SetReply](#SetReply) package it triggers.
|
||||
@@ -402,6 +419,9 @@ The following operations can be applied to a datastorage key
|
||||
| xor | Applies a bitwise Exclusive OR to the current value of the key with `value`. |
|
||||
| left_shift | Applies a bitwise left-shift to the current value of the key by `value`. |
|
||||
| right_shift | Applies a bitwise right-shift to the current value of the key by `value`. |
|
||||
| remove | List only: removes the first instance of `value` found in the list. |
|
||||
| pop | List or Dict: for lists it will remove the index of the `value` given. for dicts it removes the element with the specified key of `value`. |
|
||||
| update | Dict only: Updates the dictionary with the specified elements given in `value` creating new keys, or updating old ones if they previously existed. |
|
||||
|
||||
### SetNotify
|
||||
Used to register your current session for receiving all [SetReply](#SetReply) packages of certain keys to allow your client to keep track of changes.
|
||||
@@ -587,10 +607,24 @@ class Permission(enum.IntEnum):
|
||||
disabled = 0b000 # 0, completely disables access
|
||||
enabled = 0b001 # 1, allows manual use
|
||||
goal = 0b010 # 2, allows manual use after goal completion
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for forfeit and collect
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for release and collect
|
||||
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
|
||||
```
|
||||
|
||||
### Hint
|
||||
An object representing a Hint.
|
||||
```python
|
||||
import typing
|
||||
class Hint(typing.NamedTuple):
|
||||
receiving_player: int
|
||||
finding_player: int
|
||||
location: int
|
||||
item: int
|
||||
found: bool
|
||||
entrance: str = ""
|
||||
item_flags: int = 0
|
||||
```
|
||||
|
||||
### Data Package Contents
|
||||
A data package is a JSON object which may contain arbitrary metadata to enable a client to interact with the Archipelago server most easily. Currently, this package is used to send ID to name mappings so that clients need not maintain their own mappings.
|
||||
|
||||
@@ -622,7 +656,6 @@ Tags are represented as a list of strings, the common Client tags follow:
|
||||
| Name | Notes |
|
||||
|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
|
||||
| IgnoreGame | Deprecated. See Tracker and TextOnly. Tells the server to ignore the "game" attribute in the [Connect](#Connect) packet. |
|
||||
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
|
||||
| Tracker | Tells the server that this client will not send locations and is actually a Tracker. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
| TextOnly | Tells the server that this client will not send locations and is intended for chat. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
|
||||
188
docs/options api.md
Normal file
188
docs/options api.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Archipelago Options API
|
||||
|
||||
This document covers some of the generic options available using Archipelago's options handling system.
|
||||
|
||||
For more information on where these options go in your world please refer to:
|
||||
- [world api.md](/docs/world%20api.md)
|
||||
|
||||
Archipelago will be abbreviated as "AP" from now on.
|
||||
|
||||
## Option Definitions
|
||||
Option parsing in AP is done using different Option classes. For each option you would like to have in your game, you
|
||||
need to create:
|
||||
- A new option class with a docstring detailing what the option will do to your user.
|
||||
- A `display_name` to be displayed on the webhost.
|
||||
- A new entry in the `option_definitions` dict for your World.
|
||||
By style and convention, the internal names should be snake_case. If the option supports having multiple sub_options
|
||||
such as Choice options, these can be defined with `option_my_sub_option`, where the preceding `option_` is required and
|
||||
stripped for users, so will show as `my_sub_option` in yaml files and if `auto_display_name` is True `My Sub Option`
|
||||
on the webhost. All options support `random` as a generic option. `random` chooses from any of the available
|
||||
values for that option, and is reserved by AP. You can set this as your default value but you cannot define your own
|
||||
new `option_random`.
|
||||
|
||||
### Option Creation
|
||||
As an example, suppose we want an option that lets the user start their game with a sword in their inventory. Let's
|
||||
create our option class (with a docstring), give it a `display_name`, and add it to a dictionary that keeps track of our
|
||||
options:
|
||||
|
||||
```python
|
||||
# Options.py
|
||||
class StartingSword(Toggle):
|
||||
"""Adds a sword to your starting inventory."""
|
||||
display_name = "Start With Sword"
|
||||
|
||||
|
||||
example_options = {
|
||||
"starting_sword": StartingSword
|
||||
}
|
||||
```
|
||||
|
||||
This will create a `Toggle` option, internally called `starting_sword`. To then submit this to the multiworld, we add it
|
||||
to our world's `__init__.py`:
|
||||
|
||||
```python
|
||||
from worlds.AutoWorld import World
|
||||
from .Options import options
|
||||
|
||||
|
||||
class ExampleWorld(World):
|
||||
option_definitions = options
|
||||
```
|
||||
|
||||
### Option Checking
|
||||
Options are parsed by `Generate.py` before the worlds are created, and then the option classes are created shortly after
|
||||
world instantiation. These are created as attributes on the MultiWorld and can be accessed with
|
||||
`self.multiworld.my_option_name[self.player]`. This is the option class, which supports direct comparison methods to
|
||||
relevant objects (like comparing a Toggle class to a `bool`). If you need to access the option result directly, this is
|
||||
the option class's `value` attribute. For our example above we can do a simple check:
|
||||
```python
|
||||
if self.multiworld.starting_sword[self.player]:
|
||||
do_some_things()
|
||||
```
|
||||
|
||||
or if I need a boolean object, such as in my slot_data I can access it as:
|
||||
```python
|
||||
start_with_sword = bool(self.multiworld.starting_sword[self.player].value)
|
||||
```
|
||||
|
||||
## Generic Option Classes
|
||||
These options are generically available to every game automatically, but can be overridden for slightly different
|
||||
behavior, if desired. See `worlds/soe/Options.py` for an example.
|
||||
|
||||
### Accessibility
|
||||
Sets rules for availability of locations for the player. `Items` is for all items available but not necessarily all
|
||||
locations, such as self-locking keys, but needs to be set by the world for this to be different from locations access.
|
||||
|
||||
### ProgressionBalancing
|
||||
Algorithm for moving progression items into earlier spheres to make the gameplay experience a bit smoother. Can be
|
||||
overridden if you want a different default value.
|
||||
|
||||
### LocalItems
|
||||
Forces the players' items local to their world.
|
||||
|
||||
### NonLocalItems
|
||||
Forces the players' items outside their world.
|
||||
|
||||
### StartInventory
|
||||
Allows the player to define a dictionary of starting items with item name and quantity.
|
||||
|
||||
### StartHints
|
||||
Gives the player starting hints for where the items defined here are.
|
||||
|
||||
### StartLocationHints
|
||||
Gives the player starting hints for the items on locations defined here.
|
||||
|
||||
### ExcludeLocations
|
||||
Marks locations given here as `LocationProgressType.Excluded` so that progression items can't be placed on them.
|
||||
|
||||
### PriorityLocations
|
||||
Marks locations given here as `LocationProgressType.Priority` forcing progression items on them.
|
||||
|
||||
### ItemLinks
|
||||
Allows users to share their item pool with other players. Currently item links are per game. A link of one game between
|
||||
two players will combine their items in the link into a single item, which then gets replaced with `World.create_filler()`.
|
||||
|
||||
## Basic Option Classes
|
||||
### Toggle
|
||||
The example above. This simply has 0 and 1 as its available results with 0 (false) being the default value. Cannot be
|
||||
compared to strings but can be directly compared to True and False.
|
||||
|
||||
### DefaultOnToggle
|
||||
Like Toggle, but 1 (true) is the default value.
|
||||
|
||||
### Choice
|
||||
A numeric option allowing you to define different sub options. Values are stored as integers, but you can also do
|
||||
comparison methods with the class and strings, so if you have an `option_early_sword`, this can be compared with:
|
||||
```python
|
||||
if self.multiworld.sword_availability[self.player] == "early_sword":
|
||||
do_early_sword_things()
|
||||
```
|
||||
|
||||
or:
|
||||
```python
|
||||
from .Options import SwordAvailability
|
||||
|
||||
if self.multiworld.sword_availability[self.player] == SwordAvailability.option_early_sword:
|
||||
do_early_sword_things()
|
||||
```
|
||||
|
||||
### Range
|
||||
A numeric option allowing a variety of integers including the endpoints. Has a default `range_start` of 0 and default
|
||||
`range_end` of 1. Allows for negative values as well. This will always be an integer and has no methods for string
|
||||
comparisons.
|
||||
|
||||
### SpecialRange
|
||||
Like range but also allows you to define a dictionary of special names the user can use to equate to a specific value.
|
||||
For example:
|
||||
```python
|
||||
special_range_names: {
|
||||
"normal": 20,
|
||||
"extreme": 99,
|
||||
}
|
||||
```
|
||||
|
||||
will let users use the names "normal" or "extreme" in their options selections, but will still return those as integers
|
||||
to you. Useful if you want special handling regarding those specified values.
|
||||
|
||||
## More Advanced Options
|
||||
### FreeText
|
||||
This is an option that allows the user to enter any possible string value. Can only be compared with strings, and has
|
||||
no validation step, so if this needs to be validated, you can either add a validation step to the option class or
|
||||
within the world.
|
||||
|
||||
### TextChoice
|
||||
Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any
|
||||
user defined string as a valid option, so will either need to be validated by adding a validation step to the option
|
||||
class or within world, if necessary. Value for this class is `Union[str, int]` so if you need the value at a specified
|
||||
point, `self.multiworld.my_option[self.player].current_key` will always return a string.
|
||||
|
||||
### PlandoBosses
|
||||
An option specifically built for handling boss rando, if your game can use it. Is a subclass of TextChoice so supports
|
||||
everything it does, as well as having multiple validation steps to automatically support boss plando from users. If
|
||||
using this class, you must define `bosses`, a set of valid boss names, and `locations`, a set of valid boss location
|
||||
names, and `def can_place_boss`, which passes a boss and location, allowing you to check if that placement is valid for
|
||||
your game. When this function is called, `bosses`, `locations`, and the passed strings will all be lowercase. There is
|
||||
also a `duplicate_bosses` attribute allowing you to define if a boss can be placed multiple times in your world. False
|
||||
by default, and will reject duplicate boss names from the user. For an example of using this class, refer to
|
||||
`worlds.alttp.options.py`
|
||||
|
||||
### OptionDict
|
||||
This option returns a dictionary. Setting a default here is recommended as it will output the dictionary to the
|
||||
template. If you set a [Schema](https://pypi.org/project/schema/) on the class with `schema = Schema()`, then the
|
||||
options system will automatically validate the user supplied data against the schema to ensure it's in the correct
|
||||
format.
|
||||
|
||||
### ItemDict
|
||||
Like OptionDict, except this will verify that every key in the dictionary is a valid name for an item for your world.
|
||||
|
||||
### OptionList
|
||||
This option defines a List, where the user can add any number of strings to said list, allowing duplicate values. You
|
||||
can define a set of keys in `valid_keys`, and a default list if you want certain options to be available without editing
|
||||
for this. If `valid_keys_casefold` is true, the verification will be case-insensitive; `verify_item_name` will check
|
||||
that each value is a valid item name; and`verify_location_name` will check that each value is a valid location name.
|
||||
|
||||
### OptionSet
|
||||
Like OptionList, but returns a set, preventing duplicates.
|
||||
|
||||
### ItemSet
|
||||
Like OptionSet, but will verify that all the items in the set are a valid name for an item for your world.
|
||||
@@ -18,6 +18,7 @@
|
||||
* Use type annotations where possible for function signatures and class members.
|
||||
* Use type annotations where appropriate for local variables (e.g. `var: List[int] = []`, or when the
|
||||
type is hard or impossible to deduce.) Clear annotations help developers look up and validate API calls.
|
||||
* Worlds that do not follow PEP8 should still have a consistent style across its files to make reading easier.
|
||||
|
||||
|
||||
## Markdown
|
||||
|
||||
@@ -48,5 +48,5 @@
|
||||
# TODO
|
||||
#JSON_AS_ASCII: false
|
||||
|
||||
# Patch target. This is the address encoded into the patch that will be used for client auto-connect.
|
||||
#PATCH_TARGET: archipelago.gg
|
||||
# Host Address. This is the address encoded into the patch that will be used for client auto-connect.
|
||||
#HOST_ADDRESS: archipelago.gg
|
||||
|
||||
@@ -343,18 +343,6 @@ class MyGameWorld(World):
|
||||
option_definitions = mygame_options # assign the options dict to the world
|
||||
#...
|
||||
```
|
||||
|
||||
### Local or Remote
|
||||
|
||||
A world with `remote_items` set to `True` gets all items items from the server
|
||||
and no item from the local game. So for an RPG opening a chest would not add
|
||||
any item to your inventory, instead the server will send you what was in that
|
||||
chest. The advantage is that a generic mod can be used that does not need to
|
||||
know anything about the seed.
|
||||
|
||||
A world with `remote_items` set to `False` will locally reward its local items.
|
||||
For console games this can remove delay and make script/animation/dialog flow
|
||||
more natural. These games typically have been edited to 'bake in' the items.
|
||||
|
||||
### A World Class Skeleton
|
||||
|
||||
@@ -379,8 +367,6 @@ class MyGameWorld(World):
|
||||
game: str = "My Game" # name of the game/world
|
||||
option_definitions = mygame_options # options the player can set
|
||||
topology_present: bool = True # show path to required location checks in spoiler
|
||||
remote_items: bool = False # True if all items come from the server
|
||||
remote_start_inventory: bool = False # True if start inventory comes from the server
|
||||
|
||||
# data_version is used to signal that items, locations or their names
|
||||
# changed. Set this to 0 during development so other games' clients do not
|
||||
@@ -415,44 +401,44 @@ The world has to provide the following things for generation
|
||||
* additions to the item pool
|
||||
* additions to the regions list: at least one called "Menu"
|
||||
* locations placed inside those regions
|
||||
* a `def create_item(self, item: str) -> MyGameItem` for plando/manual placing
|
||||
* applying `self.world.precollected_items` for plando/start inventory
|
||||
if not using a `remote_start_inventory`
|
||||
* a `def generate_output(self, output_directory: str)` that creates the output
|
||||
if there is output to be generated. If only items are randomized and
|
||||
`remote_items = True` it is possible to have a generic mod and output
|
||||
generation can be skipped. In all other cases this is required. When this is
|
||||
called, `self.world.get_locations()` has all locations for all players, with
|
||||
properties `item` pointing to the item and `player` identifying the player.
|
||||
`self.world.get_filled_locations(self.player)` will filter for this world.
|
||||
`item.player` can be used to see if it's a local item.
|
||||
* a `def create_item(self, item: str) -> MyGameItem` to create any item on demand
|
||||
* applying `self.multiworld.push_precollected` for start inventory
|
||||
* `required_client_version: Tuple(int, int, int)`
|
||||
Optional client version as tuple of 3 ints to make sure the client is compatible to
|
||||
this world (e.g. implements all required features) when connecting.
|
||||
|
||||
In addition, the following methods can be implemented and attributes can be set
|
||||
In addition, the following methods can be implemented and are called in this order during generation
|
||||
|
||||
* `stage_assert_generate(cls, multiworld)` is a class method called at the start of
|
||||
generation to check the existence of prerequisite files, usually a ROM for
|
||||
games which require one.
|
||||
* `def generate_early(self)`
|
||||
called per player before any items or locations are created. You can set
|
||||
properties on your world here. Already has access to player options and RNG.
|
||||
* `def create_regions(self)`
|
||||
called to place player's regions into the MultiWorld's regions list. If it's
|
||||
hard to separate, this can be done during `generate_early` or `basic` as well.
|
||||
called to place player's regions and their locations into the MultiWorld's regions list. If it's
|
||||
hard to separate, this can be done during `generate_early` or `create_items` as well.
|
||||
* `def create_items(self)`
|
||||
called to place player's items into the MultiWorld's itempool.
|
||||
called to place player's items into the MultiWorld's itempool. After this step all regions and items have to be in
|
||||
the MultiWorld's regions and itempool, and these lists should not be modified afterwards.
|
||||
* `def set_rules(self)`
|
||||
called to set access and item rules on locations and entrances.
|
||||
called to set access and item rules on locations and entrances.
|
||||
Locations have to be defined before this, or rule application can miss them.
|
||||
* `def generate_basic(self)`
|
||||
called after the previous steps. Some placement and player specific
|
||||
randomizations can be done here. After this step all regions and items have
|
||||
to be in the MultiWorld's regions and itempool.
|
||||
randomizations can be done here.
|
||||
* `pre_fill`, `fill_hook` and `post_fill` are called to modify item placement
|
||||
before, during and after the regular fill process, before `generate_output`.
|
||||
If items need to be placed during pre_fill, these items can be determined
|
||||
and created using `get_prefill_items`
|
||||
* `def generate_output(self, output_directory: str)` that creates the output
|
||||
files if there is output to be generated. When this is
|
||||
called, `self.multiworld.get_locations(self.player)` has all locations for the player, with
|
||||
attribute `item` pointing to the item.
|
||||
`location.item.player` can be used to see if it's a local item.
|
||||
* `fill_slot_data` and `modify_multidata` can be used to modify the data that
|
||||
will be used by the server to host the MultiWorld.
|
||||
* `required_client_version: Tuple(int, int, int)`
|
||||
Client version as tuple of 3 ints to make sure the client is compatible to
|
||||
this world (e.g. implements all required features) when connecting.
|
||||
* `assert_generate(cls, world)` is a class method called at the start of
|
||||
generation to check the existence of prerequisite files, usually a ROM for
|
||||
games which require one.
|
||||
|
||||
|
||||
#### generate_early
|
||||
|
||||
@@ -514,21 +500,21 @@ def create_items(self) -> None:
|
||||
```python
|
||||
def create_regions(self) -> None:
|
||||
# Add regions to the multiworld. "Menu" is the required starting point.
|
||||
# Arguments to Region() are name, type, human_readable_name, player, world
|
||||
r = Region("Menu", RegionType.Generic, "Menu", self.player, self.multiworld)
|
||||
# Arguments to Region() are name, player, world, and optionally hint_text
|
||||
r = Region("Menu", self.player, self.multiworld)
|
||||
# Set Region.exits to a list of entrances that are reachable from region
|
||||
r.exits = [Entrance(self.player, "New game", r)] # or use r.exits.append
|
||||
# Append region to MultiWorld's regions
|
||||
self.multiworld.regions.append(r) # or use += [r...]
|
||||
|
||||
r = Region("Main Area", RegionType.Generic, "Main Area", self.player, self.multiworld)
|
||||
r = Region("Main Area", self.player, self.multiworld)
|
||||
# Add main area's locations to main area (all but final boss)
|
||||
r.locations = [MyGameLocation(self.player, location.name,
|
||||
self.location_name_to_id[location.name], r)]
|
||||
r.exits = [Entrance(self.player, "Boss Door", r)]
|
||||
self.multiworld.regions.append(r)
|
||||
|
||||
r = Region("Boss Room", RegionType.Generic, "Boss Room", self.player, self.multiworld)
|
||||
r = Region("Boss Room", self.player, self.multiworld)
|
||||
# add event to Boss Room
|
||||
r.locations = [MyGameLocation(self.player, "Final Boss", None, r)]
|
||||
self.multiworld.regions.append(r)
|
||||
@@ -677,6 +663,7 @@ def generate_output(self, output_directory: str):
|
||||
if location.item.player == self.player else "Remote"
|
||||
for location in self.multiworld.get_filled_locations(self.player)},
|
||||
# store start_inventory from player's .yaml
|
||||
# make sure to mark as not remote_start_inventory when connecting if stored in rom/mod
|
||||
"starter_items": [item.name for item
|
||||
in self.multiworld.precollected_items[self.player]],
|
||||
"final_boss_hp": self.final_boss_hp,
|
||||
@@ -696,3 +683,60 @@ def generate_output(self, output_directory: str):
|
||||
generate_mod(src, out_file, data)
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
Each world implementation should have a tutorial and a game info page. These are both rendered on the website by reading
|
||||
the `.md` files in your world's `/docs` directory.
|
||||
|
||||
#### Game Info
|
||||
The game info page is for a short breakdown of what your game is and how it works in Archipelago. Any additional
|
||||
information that may be useful to the player when learning your randomizer should also go here. The file name format
|
||||
is `<language key>_<game name>.md`. While you can write these docs for multiple languages, currently only the english
|
||||
version is displayed on the website.
|
||||
|
||||
#### Tutorials
|
||||
Your game can have as many tutorials in as many languages as you like, with each one having a relevant `Tutorial`
|
||||
defined in the `WebWorld`. The file name you use aren't particularly important, but it should be descriptive of what
|
||||
the tutorial is covering, and the name of the file must match the relative URL provided in the `Tutorial`. Currently,
|
||||
the JS that determines this ignores the provided file name and will search for `game/document_lang.md`, where
|
||||
`game/document/lang` is the provided URL.
|
||||
|
||||
### Tests
|
||||
|
||||
Each world is expected to include unit tests that cover its logic, to ensure no logic bug regressions occur. This can be
|
||||
done by creating a `/test` package within your world package. The `__init__.py` within this folder is where the world's
|
||||
TestBase should be defined. This can be inherited from the main TestBase, which will automatically set up a solo
|
||||
multiworld for each test written using it. Within subsequent modules, classes should be defined which inherit the world
|
||||
TestBase, and can then define options to test in the class body, and run tests in each test method.
|
||||
|
||||
Example `__init__.py`
|
||||
```python
|
||||
from test.TestBase import WorldTestBase
|
||||
|
||||
|
||||
class MyGameTestBase(WorldTestBase):
|
||||
game = "My Game"
|
||||
```
|
||||
|
||||
Next using the rules defined in the above `set_rules` we can test that the chests have the correct access rules.
|
||||
|
||||
Example `testChestAccess.py`
|
||||
```python
|
||||
from . import MyGameTestBase
|
||||
|
||||
|
||||
class TestChestAccess(MyGameTestBase):
|
||||
def testSwordChests(self):
|
||||
"""Test locations that require a sword"""
|
||||
locations = ["Chest1", "Chest2"]
|
||||
items = [["Sword"]]
|
||||
# this will test that each location can't be accessed without the "Sword", but can be accessed once obtained.
|
||||
self.assertAccessDependency(locations, items)
|
||||
|
||||
def testAnyWeaponChests(self):
|
||||
"""Test locations that require any weapon"""
|
||||
locations = [f"Chest{i}" for i in range(3, 6)]
|
||||
items = [["Sword"], ["Axe"], ["Spear"]]
|
||||
# this will test that chests 3-5 can't be accessed without any weapon, but can be with just one of them.
|
||||
self.assertAccessDependency(locations, items)
|
||||
```
|
||||
|
||||
43
host.yaml
43
host.yaml
@@ -22,14 +22,14 @@ server_options:
|
||||
# Relative point cost to receive a hint via !hint for players
|
||||
# so for example hint_cost: 20 would mean that for every 20% of available checks, you get the ability to hint, for a total of 5
|
||||
hint_cost: 10 # Set to 0 if you want free hints
|
||||
# Forfeit modes
|
||||
# A Forfeit sends out the remaining items *from* a world that forfeits
|
||||
# "disabled" -> clients can't forfeit,
|
||||
# "enabled" -> clients can always forfeit
|
||||
# "auto" -> automatic forfeit on goal completion
|
||||
# "auto-enabled" -> automatic forfeit on goal completion and manual forfeit is also enabled
|
||||
# "goal" -> forfeit is allowed after goal completion
|
||||
forfeit_mode: "goal"
|
||||
# Release modes
|
||||
# A Release sends out the remaining items *from* a world that releases
|
||||
# "disabled" -> clients can't release,
|
||||
# "enabled" -> clients can always release
|
||||
# "auto" -> automatic release on goal completion
|
||||
# "auto-enabled" -> automatic release on goal completion and manual release is also enabled
|
||||
# "goal" -> release is allowed after goal completion
|
||||
release_mode: "goal"
|
||||
# Collect modes
|
||||
# A Collect sends the remaining items *to* a world that collects
|
||||
# "disabled" -> clients can't collect,
|
||||
@@ -68,9 +68,10 @@ generator:
|
||||
meta_file_path: "meta.yaml"
|
||||
# Create a spoiler file
|
||||
# 0 -> None
|
||||
# 1 -> Spoiler without playthrough
|
||||
# 2 -> Full spoiler
|
||||
spoiler: 2
|
||||
# 1 -> Spoiler without playthrough or paths to playthrough required items
|
||||
# 2 -> Spoiler with playthrough (viable solution to goals)
|
||||
# 3 -> Spoiler with playthrough and traversal paths towards items
|
||||
spoiler: 3
|
||||
# Glitch to Triforce room from Ganon
|
||||
# When disabled, you have to have a weapon that can hurt ganon (master sword or swordless/easy item functionality + hammer)
|
||||
# and have completed the goal required for killing ganon to be able to access the triforce room.
|
||||
@@ -92,6 +93,9 @@ sni_options:
|
||||
lttp_options:
|
||||
# File name of the v1.0 J rom
|
||||
rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
|
||||
lufia2ac_options:
|
||||
# File name of the US rom
|
||||
rom_file: "Lufia II - Rise of the Sinistrals (USA).sfc"
|
||||
sm_options:
|
||||
# File name of the v1.0 J rom
|
||||
rom_file: "Super Metroid (JU).sfc"
|
||||
@@ -103,7 +107,7 @@ factorio_options:
|
||||
filter_item_sends: false
|
||||
# Whether to send chat messages from players on the Factorio server to Archipelago.
|
||||
bridge_chat_out: true
|
||||
minecraft_options:
|
||||
minecraft_options:
|
||||
forge_directory: "Minecraft Forge server"
|
||||
max_heap_size: "2G"
|
||||
# release channel, currently "release", or "beta"
|
||||
@@ -121,6 +125,15 @@ soe_options:
|
||||
rom_file: "Secret of Evermore (USA).sfc"
|
||||
ffr_options:
|
||||
display_msgs: true
|
||||
tloz_options:
|
||||
# File name of the Zelda 1
|
||||
rom_file: "Legend of Zelda, The (U) (PRG0) [!].nes"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# true for operating system default program
|
||||
# Alternatively, a path to a program to open the .nes file with
|
||||
rom_start: true
|
||||
# Display message inside of Bizhawk
|
||||
display_msgs: true
|
||||
dkc3_options:
|
||||
# File name of the DKC3 US rom
|
||||
rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
|
||||
@@ -135,6 +148,12 @@ pokemon_rb_options:
|
||||
# True for operating system default program
|
||||
# Alternatively, a path to a program to open the .gb file with
|
||||
rom_start: true
|
||||
|
||||
wargroove_options:
|
||||
# Locate the Wargroove root directory on your system.
|
||||
# This is used by the Wargroove client, so it knows where to send communication files to
|
||||
root_directory: "C:/Program Files (x86)/Steam/steamapps/common/Wargroove"
|
||||
|
||||
zillion_options:
|
||||
# File name of the Zillion US rom
|
||||
rom_file: "Zillion (UE) [!].sms"
|
||||
|
||||
@@ -25,9 +25,9 @@ OutputBaseFilename=Setup {#MyAppName} {#MyAppVersionText}
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
LZMANumBlockThreads=8
|
||||
ArchitecturesInstallIn64BitMode=x64
|
||||
ArchitecturesInstallIn64BitMode=x64 arm64
|
||||
ChangesAssociations=yes
|
||||
ArchitecturesAllowed=x64
|
||||
ArchitecturesAllowed=x64 arm64
|
||||
AllowNoIcons=yes
|
||||
SetupIconFile={#MyAppIcon}
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
@@ -57,6 +57,7 @@ Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full ho
|
||||
Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||
Name: "generator/smw"; Description: "Super Mario World ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||
Name: "generator/l2ac"; Description: "Lufia II Ancient Cave ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 2621440; Flags: disablenouninstallwarning
|
||||
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
|
||||
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
|
||||
Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning
|
||||
@@ -69,6 +70,7 @@ Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Se
|
||||
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||
Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||
Name: "client/sni/l2ac"; Description: "SNI Client - Lufia II Ancient Cave Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||
Name: "client/factorio"; Description: "Factorio"; Types: full playing
|
||||
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
|
||||
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
|
||||
@@ -90,6 +92,7 @@ Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).s
|
||||
Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3
|
||||
Source: "{code:GetSMWROMPath}"; DestDir: "{app}"; DestName: "Super Mario World (USA).sfc"; Flags: external; Components: client/sni/smw or generator/smw
|
||||
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
|
||||
Source: "{code:GetL2ACROMPath}"; DestDir: "{app}"; DestName: "Lufia II - Rise of the Sinistrals (USA).sfc"; Flags: external; Components: generator/l2ac
|
||||
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
|
||||
Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl
|
||||
Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r
|
||||
@@ -152,6 +155,7 @@ Type: dirifempty; Name: "{app}"
|
||||
[InstallDelete]
|
||||
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
|
||||
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
|
||||
#include "installdelete.iss"
|
||||
|
||||
[Registry]
|
||||
|
||||
@@ -190,6 +194,11 @@ Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Arch
|
||||
Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||
|
||||
Root: HKCR; Subkey: ".apl2ac"; ValueData: "{#MyAppName}l2acpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}l2acpatch"; ValueData: "Archipelago Lufia II Ancient Cave Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||
|
||||
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft
|
||||
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft
|
||||
Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: ""; Components: client/minecraft
|
||||
@@ -200,15 +209,15 @@ Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archip
|
||||
Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Components: client/oot
|
||||
Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/oot
|
||||
|
||||
Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||
Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
|
||||
Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||
Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
|
||||
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server
|
||||
@@ -262,6 +271,9 @@ var SMWRomFilePage: TInputFileWizardPage;
|
||||
var soerom: string;
|
||||
var SoERomFilePage: TInputFileWizardPage;
|
||||
|
||||
var l2acrom: string;
|
||||
var L2ACROMFilePage: TInputFileWizardPage;
|
||||
|
||||
var ootrom: string;
|
||||
var OoTROMFilePage: TInputFileWizardPage;
|
||||
|
||||
@@ -422,6 +434,8 @@ begin
|
||||
Result := not (SMWROMFilePage.Values[0] = '')
|
||||
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
|
||||
Result := not (SoEROMFilePage.Values[0] = '')
|
||||
else if (assigned(L2ACROMFilePage)) and (CurPageID = L2ACROMFilePage.ID) then
|
||||
Result := not (L2ACROMFilePage.Values[0] = '')
|
||||
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
|
||||
Result := not (OoTROMFilePage.Values[0] = '')
|
||||
else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then
|
||||
@@ -526,6 +540,22 @@ begin
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
function GetL2ACROMPath(Param: string): string;
|
||||
begin
|
||||
if Length(l2acrom) > 0 then
|
||||
Result := l2acrom
|
||||
else if Assigned(L2ACROMFilePage) then
|
||||
begin
|
||||
R := CompareStr(GetSNESMD5OfFile(L2ACROMFilePage.Values[0]), '6efc477d6203ed2b3b9133c1cd9e9c5d')
|
||||
if R <> 0 then
|
||||
MsgBox('Lufia II ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
||||
|
||||
Result := L2ACROMFilePage.Values[0]
|
||||
end
|
||||
else
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
function GetZlROMPath(Param: string): string;
|
||||
begin
|
||||
if Length(zlrom) > 0 then
|
||||
@@ -609,6 +639,10 @@ begin
|
||||
bluerom := CheckRom('Pokemon Blue (UE) [S][!].gb','50927e843568814f7ed45ec4f944bd8b');
|
||||
if Length(bluerom) = 0 then
|
||||
BlueROMFilePage:= AddGBRomPage('Pokemon Blue (UE) [S][!].gb');
|
||||
|
||||
l2acrom := CheckRom('Lufia II - Rise of the Sinistrals (USA).sfc', '6efc477d6203ed2b3b9133c1cd9e9c5d');
|
||||
if Length(l2acrom) = 0 then
|
||||
L2ACROMFilePage:= AddRomPage('Lufia II - Rise of the Sinistrals (USA).sfc');
|
||||
end;
|
||||
|
||||
|
||||
@@ -623,6 +657,8 @@ begin
|
||||
Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3'));
|
||||
if (assigned(SMWROMFilePage)) and (PageID = SMWROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('client/sni/smw') or WizardIsComponentSelected('generator/smw'));
|
||||
if (assigned(L2ACROMFilePage)) and (PageID = L2ACROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('client/sni/l2ac') or WizardIsComponentSelected('generator/l2ac'));
|
||||
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('generator/soe'));
|
||||
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
|
||||
|
||||
44
kvui.py
44
kvui.py
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import logging
|
||||
import typing
|
||||
import asyncio
|
||||
|
||||
os.environ["KIVY_NO_CONSOLELOG"] = "1"
|
||||
os.environ["KIVY_NO_FILELOG"] = "1"
|
||||
@@ -26,6 +25,7 @@ from kivy.base import ExceptionHandler, ExceptionManager
|
||||
from kivy.clock import Clock
|
||||
from kivy.factory import Factory
|
||||
from kivy.properties import BooleanProperty, ObjectProperty
|
||||
from kivy.uix.widget import Widget
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.layout import Layout
|
||||
@@ -330,6 +330,12 @@ class GameManager(App):
|
||||
|
||||
super(GameManager, self).__init__()
|
||||
|
||||
@property
|
||||
def tab_count(self):
|
||||
if hasattr(self, "tabs"):
|
||||
return max(1, len(self.tabs.tab_list))
|
||||
return 1
|
||||
|
||||
def build(self) -> Layout:
|
||||
self.container = ContainerLayout()
|
||||
|
||||
@@ -392,11 +398,13 @@ class GameManager(App):
|
||||
Clock.schedule_interval(self.update_texts, 1 / 30)
|
||||
self.container.add_widget(self.grid)
|
||||
|
||||
# If the address contains a port, select it; otherwise, select the host.
|
||||
s = self.server_connect_bar.text
|
||||
host_start = s.find("@") + 1
|
||||
ipv6_end = s.find("]", host_start) + 1
|
||||
port_start = s.find(":", ipv6_end if ipv6_end > 0 else host_start) + 1
|
||||
self.server_connect_bar.focus = True
|
||||
self.server_connect_bar.select_text(
|
||||
self.server_connect_bar.text.find(":") + 1,
|
||||
len(self.server_connect_bar.text)
|
||||
)
|
||||
self.server_connect_bar.select_text(port_start if port_start > 0 else host_start, len(s))
|
||||
|
||||
return self.container
|
||||
|
||||
@@ -500,7 +508,7 @@ class LogtoUI(logging.Handler):
|
||||
|
||||
|
||||
class UILog(RecycleView):
|
||||
cols = 1
|
||||
messages: typing.ClassVar[int] # comes from kv file
|
||||
|
||||
def __init__(self, *loggers_to_handle, **kwargs):
|
||||
super(UILog, self).__init__(**kwargs)
|
||||
@@ -510,9 +518,15 @@ class UILog(RecycleView):
|
||||
|
||||
def on_log(self, record: str) -> None:
|
||||
self.data.append({"text": escape_markup(record)})
|
||||
self.clean_old()
|
||||
|
||||
def on_message_markup(self, text):
|
||||
self.data.append({"text": text})
|
||||
self.clean_old()
|
||||
|
||||
def clean_old(self):
|
||||
if len(self.data) > self.messages:
|
||||
self.data.pop(0)
|
||||
|
||||
def fix_heights(self):
|
||||
"""Workaround fix for divergent texture and layout heights"""
|
||||
@@ -530,6 +544,19 @@ class E(ExceptionHandler):
|
||||
|
||||
|
||||
class KivyJSONtoTextParser(JSONtoTextParser):
|
||||
# dummy class to absorb kvlang definitions
|
||||
class TextColors(Widget):
|
||||
pass
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# we grab the color definitions from the .kv file, then overwrite the JSONtoTextParser default entries
|
||||
colors = self.TextColors()
|
||||
color_codes = self.color_codes.copy()
|
||||
for name, code in color_codes.items():
|
||||
color_codes[name] = getattr(colors, name, code)
|
||||
self.color_codes = color_codes
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
self.ref_count = 0
|
||||
return super(KivyJSONtoTextParser, self).__call__(*args, **kwargs)
|
||||
@@ -579,3 +606,8 @@ class KivyJSONtoTextParser(JSONtoTextParser):
|
||||
ExceptionManager.add_handler(E())
|
||||
|
||||
Builder.load_file(Utils.local_path("data", "client.kv"))
|
||||
user_file = Utils.local_path("data", "user.kv")
|
||||
if os.path.exists(user_file):
|
||||
logging.info("Loading user.kv into builder.")
|
||||
Builder.load_file(user_file)
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
meta_description: Meta-Mystery file with the intention of having similar-length completion times for a hopefully better experience
|
||||
null:
|
||||
progression_balancing: # Progression balancing tries to make sure that the player has *something* towards any players goal in each "sphere"
|
||||
on: 0 # Force every player into progression balancing
|
||||
off: 0 # Force every player out of progression balancing, then prepare for a lot of logical BK
|
||||
null: 1 # Let players decide via their own progression_balancing flag in their yaml, defaulting to on
|
||||
normal: 0 # Force every player into default progression balancing
|
||||
disabled: 0 # Force every player out of progression balancing, then prepare for a lot of logical BK
|
||||
null: 1 # Let players decide via their own progression_balancing setting in their yaml, defaulting to 50
|
||||
A Link to the Past:
|
||||
goals:
|
||||
ganon: 100 # Climb GT, defeat Agahnim 2, and then kill Ganon
|
||||
@@ -36,4 +36,4 @@ A Link to the Past:
|
||||
30: 50
|
||||
triforce_pieces_required: # Set to how many out of X triforce pieces you need to win the game in a triforce hunt. Default is 20. Max is 90, Min is 1
|
||||
# Format "pieces: chance"
|
||||
25: 50
|
||||
25: 50
|
||||
|
||||
@@ -27,17 +27,62 @@ game: # Pick a game to play
|
||||
A Link to the Past: 1
|
||||
requires:
|
||||
version: 0.3.3 # Version of Archipelago required for this yaml to work as expected.
|
||||
# Shared Options supported by all games:
|
||||
accessibility:
|
||||
items: 0 # Guarantees you will be able to acquire all items, but you may not be able to access all locations
|
||||
locations: 50 # Guarantees you will be able to access all locations, and therefore all items
|
||||
none: 0 # Guarantees only that the game is beatable. You may not be able to access all locations or acquire all items
|
||||
progression_balancing: # A system to reduce BK, as in times during which you can't do anything, by moving your items into an earlier access sphere
|
||||
0: 0 # Choose a lower number if you don't mind a longer multiworld, or can glitch/sequence break around missing items.
|
||||
25: 0
|
||||
50: 50 # Make it likely you have stuff to do.
|
||||
99: 0 # Get important items early, and stay at the front of the progression.
|
||||
A Link to the Past:
|
||||
progression_balancing:
|
||||
# A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||
# A lower setting means more getting stuck. A higher setting means less getting stuck.
|
||||
#
|
||||
# You can define additional values between the minimum and maximum values.
|
||||
# Minimum value is 0
|
||||
# Maximum value is 99
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
disabled: 0 # equivalent to 0
|
||||
normal: 50 # equivalent to 50
|
||||
extreme: 0 # equivalent to 99
|
||||
|
||||
accessibility:
|
||||
# Set rules for reachability of your items/locations.
|
||||
# Locations: ensure everything can be reached and acquired.
|
||||
# Items: ensure all logically relevant items can be acquired.
|
||||
# Minimal: ensure what is needed to reach your goal can be acquired.
|
||||
locations: 0
|
||||
items: 50
|
||||
minimal: 0
|
||||
|
||||
local_items:
|
||||
# Forces these items to be in their native world.
|
||||
[ ]
|
||||
|
||||
non_local_items:
|
||||
# Forces these items to be outside their native world.
|
||||
[ ]
|
||||
|
||||
start_inventory:
|
||||
# Start with these items.
|
||||
{ }
|
||||
|
||||
start_hints:
|
||||
# Start with these item's locations prefilled into the !hint command.
|
||||
[ ]
|
||||
|
||||
start_location_hints:
|
||||
# Start with these locations and their item prefilled into the !hint command
|
||||
[ ]
|
||||
|
||||
exclude_locations:
|
||||
# Prevent these locations from having an important item
|
||||
[ ]
|
||||
|
||||
priority_locations:
|
||||
# Prevent these locations from having an unimportant item
|
||||
[ ]
|
||||
|
||||
item_links:
|
||||
# Share part of your item pool with other players.
|
||||
[ ]
|
||||
|
||||
### Logic Section ###
|
||||
glitches_required: # Determine the logic required to complete the seed
|
||||
none: 50 # No glitches required
|
||||
@@ -120,8 +165,8 @@ A Link to the Past:
|
||||
open_pyramid:
|
||||
goal: 50 # Opens the pyramid if the goal requires you to kill Ganon, unless the goal is Slow Ganon or All Dungeons
|
||||
auto: 0 # Same as Goal, but also is closed if holes are shuffled and ganon is part of the shuffle pool
|
||||
yes: 0 # Pyramid hole is always open. Ganon's vulnerable condition is still required before he can he hurt
|
||||
no: 0 # Pyramid hole is always closed until you defeat Agahnim atop Ganon's Tower
|
||||
open: 0 # Pyramid hole is always open. Ganon's vulnerable condition is still required before he can he hurt
|
||||
closed: 0 # Pyramid hole is always closed until you defeat Agahnim atop Ganon's Tower
|
||||
triforce_pieces_mode: #Determine how to calculate the extra available triforce pieces.
|
||||
extra: 0 # available = triforce_pieces_extra + triforce_pieces_required
|
||||
percentage: 0 # available = (triforce_pieces_percentage /100) * triforce_pieces_required
|
||||
|
||||
170
setup.py
170
setup.py
@@ -1,24 +1,27 @@
|
||||
import base64
|
||||
import datetime
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
import sysconfig
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from hashlib import sha3_512
|
||||
import base64
|
||||
import datetime
|
||||
from Utils import version_tuple, is_windows, is_linux
|
||||
from collections.abc import Iterable
|
||||
import typing
|
||||
import setuptools
|
||||
from Launcher import components, icon_paths
|
||||
import zipfile
|
||||
import urllib.request
|
||||
import io
|
||||
import json
|
||||
import threading
|
||||
import subprocess
|
||||
import pkg_resources
|
||||
|
||||
from collections.abc import Iterable
|
||||
from hashlib import sha3_512
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
||||
import subprocess
|
||||
import pkg_resources
|
||||
requirement = 'cx-Freeze>=6.13.1'
|
||||
try:
|
||||
requirement = 'cx-Freeze>=6.14.7'
|
||||
pkg_resources.require(requirement)
|
||||
import cx_Freeze
|
||||
except pkg_resources.ResolutionError:
|
||||
@@ -27,12 +30,101 @@ except pkg_resources.ResolutionError:
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', requirement, '--upgrade'])
|
||||
import cx_Freeze
|
||||
|
||||
# .build only exists if cx-Freeze is the right version, so we have to update/install that first before this line
|
||||
import setuptools.command.build
|
||||
|
||||
if __name__ == "__main__":
|
||||
# need to run this early to import from Utils and Launcher
|
||||
# TODO: move stuff to not require this
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update(yes="--yes" in sys.argv or "-y" in sys.argv)
|
||||
ModuleUpdate.update_ran = False # restore for later
|
||||
|
||||
from Launcher import components, icon_paths
|
||||
from Utils import version_tuple, is_windows, is_linux
|
||||
|
||||
|
||||
# On Python < 3.10 LogicMixin is not currently supported.
|
||||
apworlds: set = {
|
||||
"Subnautica",
|
||||
"Factorio",
|
||||
"Rogue Legacy",
|
||||
"Donkey Kong Country 3",
|
||||
"Super Mario World",
|
||||
"Stardew Valley",
|
||||
"Timespinner",
|
||||
"Minecraft",
|
||||
"The Messenger",
|
||||
}
|
||||
|
||||
|
||||
def download_SNI():
|
||||
print("Updating SNI")
|
||||
machine_to_go = {
|
||||
"x86_64": "amd64",
|
||||
"aarch64": "arm64",
|
||||
"armv7l": "arm"
|
||||
}
|
||||
platform_name = platform.system().lower()
|
||||
machine_name = platform.machine().lower()
|
||||
# force amd64 on macos until we have universal2 sni, otherwise resolve to GOARCH
|
||||
machine_name = "amd64" if platform_name == "darwin" else machine_to_go.get(machine_name, machine_name)
|
||||
with urllib.request.urlopen("https://api.github.com/repos/alttpo/sni/releases/latest") as request:
|
||||
data = json.load(request)
|
||||
files = data["assets"]
|
||||
|
||||
source_url = None
|
||||
|
||||
for file in files:
|
||||
download_url: str = file["browser_download_url"]
|
||||
machine_match = download_url.rsplit("-", 1)[1].split(".", 1)[0] == machine_name
|
||||
if platform_name in download_url and machine_match:
|
||||
# prefer "many" builds
|
||||
if "many" in download_url:
|
||||
source_url = download_url
|
||||
break
|
||||
source_url = download_url
|
||||
|
||||
if source_url and source_url.endswith(".zip"):
|
||||
with urllib.request.urlopen(source_url) as download:
|
||||
with zipfile.ZipFile(io.BytesIO(download.read()), "r") as zf:
|
||||
for member in zf.infolist():
|
||||
zf.extract(member, path="SNI")
|
||||
print(f"Downloaded SNI from {source_url}")
|
||||
|
||||
elif source_url and (source_url.endswith(".tar.xz") or source_url.endswith(".tar.gz")):
|
||||
import tarfile
|
||||
mode = "r:xz" if source_url.endswith(".tar.xz") else "r:gz"
|
||||
with urllib.request.urlopen(source_url) as download:
|
||||
sni_dir = None
|
||||
with tarfile.open(fileobj=io.BytesIO(download.read()), mode=mode) as tf:
|
||||
for member in tf.getmembers():
|
||||
if member.name.startswith("/") or "../" in member.name:
|
||||
raise ValueError(f"Unexpected file '{member.name}' in {source_url}")
|
||||
elif member.isdir() and not sni_dir:
|
||||
sni_dir = member.name
|
||||
elif member.isfile() and not sni_dir or not member.name.startswith(sni_dir):
|
||||
raise ValueError(f"Expected folder before '{member.name}' in {source_url}")
|
||||
elif member.isfile() and sni_dir:
|
||||
tf.extract(member)
|
||||
# sadly SNI is in its own folder on non-windows, so we need to rename
|
||||
shutil.rmtree("SNI", True)
|
||||
os.rename(sni_dir, "SNI")
|
||||
print(f"Downloaded SNI from {source_url}")
|
||||
|
||||
elif source_url:
|
||||
print(f"Don't know how to extract SNI from {source_url}")
|
||||
|
||||
else:
|
||||
print(f"No SNI found for system spec {platform_name} {machine_name}")
|
||||
|
||||
|
||||
if os.path.exists("X:/pw.txt"):
|
||||
print("Using signtool")
|
||||
with open("X:/pw.txt", encoding="utf-8-sig") as f:
|
||||
pw = f.read()
|
||||
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + r'" /fd sha256 /tr http://timestamp.digicert.com/ '
|
||||
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + \
|
||||
r'" /fd sha256 /tr http://timestamp.digicert.com/ '
|
||||
else:
|
||||
signtool = None
|
||||
|
||||
@@ -55,6 +147,7 @@ exes = [
|
||||
]
|
||||
|
||||
extra_data = ["LICENSE", "data", "EnemizerCLI", "host.yaml", "SNI"]
|
||||
extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else []
|
||||
|
||||
|
||||
def remove_sprites_from_folder(folder):
|
||||
@@ -70,7 +163,7 @@ def _threaded_hash(filepath):
|
||||
|
||||
|
||||
# cx_Freeze's build command runs other commands. Override to accept --yes and store that.
|
||||
class BuildCommand(cx_Freeze.command.build.Build):
|
||||
class BuildCommand(setuptools.command.build.build):
|
||||
user_options = [
|
||||
('yes', 'y', 'Answer "yes" to all questions.'),
|
||||
]
|
||||
@@ -94,6 +187,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
||||
]
|
||||
yes: bool
|
||||
extra_data: Iterable # [any] not available in 3.8
|
||||
extra_libs: Iterable # work around broken include_files
|
||||
|
||||
buildfolder: Path
|
||||
libfolder: Path
|
||||
@@ -104,6 +198,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
||||
super().initialize_options()
|
||||
self.yes = BuildCommand.last_yes
|
||||
self.extra_data = []
|
||||
self.extra_libs = []
|
||||
|
||||
def finalize_options(self):
|
||||
super().finalize_options()
|
||||
@@ -149,6 +244,10 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
||||
print("Created Manifest")
|
||||
|
||||
def run(self):
|
||||
# start downloading sni asap
|
||||
sni_thread = threading.Thread(target=download_SNI, name="SNI Downloader")
|
||||
sni_thread.start()
|
||||
|
||||
# pre build steps
|
||||
print(f"Outputting to: {self.buildfolder}")
|
||||
os.makedirs(self.buildfolder, exist_ok=True)
|
||||
@@ -160,17 +259,25 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
||||
self.buildtime = datetime.datetime.utcnow()
|
||||
super().run()
|
||||
|
||||
# include_files seems to be broken with this setup. implement here
|
||||
# need to finish download before copying
|
||||
sni_thread.join()
|
||||
|
||||
# include_files seems to not be done automatically. implement here
|
||||
for src, dst in self.include_files:
|
||||
print('copying', src, '->', self.buildfolder / dst)
|
||||
print(f"copying {src} -> {self.buildfolder / dst}")
|
||||
shutil.copyfile(src, self.buildfolder / dst, follow_symlinks=False)
|
||||
|
||||
# now that include_files is completely broken, run find_libs here
|
||||
for src, dst in find_libs(*self.extra_libs):
|
||||
print(f"copying {src} -> {self.buildfolder / dst}")
|
||||
shutil.copyfile(src, self.buildfolder / dst, follow_symlinks=False)
|
||||
|
||||
# post build steps
|
||||
if sys.platform == "win32": # kivy_deps is win32 only, linux picks them up automatically
|
||||
if is_windows: # kivy_deps is win32 only, linux picks them up automatically
|
||||
from kivy_deps import sdl2, glew
|
||||
for folder in sdl2.dep_bins + glew.dep_bins:
|
||||
shutil.copytree(folder, self.libfolder, dirs_exist_ok=True)
|
||||
print('copying', folder, '->', self.libfolder)
|
||||
print(f"copying {folder} -> {self.libfolder}")
|
||||
|
||||
for data in self.extra_data:
|
||||
self.installfile(Path(data))
|
||||
@@ -185,11 +292,26 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
||||
from WebHostLib.options import create
|
||||
create()
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
assert not apworlds - set(AutoWorldRegister.world_types), "Unknown world designated for .apworld"
|
||||
folders_to_remove: typing.List[str] = []
|
||||
for worldname, worldtype in AutoWorldRegister.world_types.items():
|
||||
if not worldtype.hidden:
|
||||
file_name = worldname+".yaml"
|
||||
shutil.copyfile(os.path.join("WebHostLib", "static", "generated", "configs", file_name),
|
||||
self.buildfolder / "Players" / "Templates" / file_name)
|
||||
if worldname in apworlds:
|
||||
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
||||
world_directory = self.libfolder / "worlds" / file_name
|
||||
# this method creates an apworld that cannot be moved to a different OS or minor python version,
|
||||
# which should be ok
|
||||
with zipfile.ZipFile(self.libfolder / "worlds" / (file_name + ".apworld"), "x", zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9) as zf:
|
||||
entry: os.DirEntry
|
||||
for path in world_directory.rglob("*.*"):
|
||||
relative_path = os.path.join(*path.parts[path.parts.index("worlds")+1:])
|
||||
zf.write(path, relative_path)
|
||||
folders_to_remove.append(file_name)
|
||||
shutil.rmtree(world_directory)
|
||||
shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml")
|
||||
# TODO: fix LttP options one day
|
||||
shutil.copyfile("playerSettings.yaml", self.buildfolder / "Players" / "Templates" / "A Link to the Past.yaml")
|
||||
@@ -218,9 +340,13 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
||||
self.create_manifest()
|
||||
|
||||
if is_windows:
|
||||
# Inno setup stuff
|
||||
with open("setup.ini", "w") as f:
|
||||
min_supported_windows = "6.2.9200" if sys.version_info > (3, 9) else "6.0.6000"
|
||||
f.write(f"[Data]\nsource_path={self.buildfolder}\nmin_windows={min_supported_windows}\n")
|
||||
with open("installdelete.iss", "w") as f:
|
||||
f.writelines("Type: filesandordirs; Name: \"{app}\\lib\\worlds\\"+world_directory+"\"\n"
|
||||
for world_directory in folders_to_remove)
|
||||
else:
|
||||
# make sure extra programs are executable
|
||||
enemizer_exe = self.buildfolder / 'EnemizerCLI/EnemizerCLI.Core'
|
||||
@@ -352,6 +478,9 @@ $APPDIR/$exe "$@"
|
||||
|
||||
def find_libs(*args: str) -> typing.Sequence[typing.Tuple[str, str]]:
|
||||
"""Try to find system libraries to be included."""
|
||||
if not args:
|
||||
return []
|
||||
|
||||
arch = build_arch.replace('_', '-')
|
||||
libc = 'libc6' # we currently don't support musl
|
||||
|
||||
@@ -418,12 +547,13 @@ cx_Freeze.setup(
|
||||
"pandas"],
|
||||
"zip_include_packages": ["*"],
|
||||
"zip_exclude_packages": ["worlds", "sc2"],
|
||||
"include_files": find_libs("libssl.so", "libcrypto.so") if is_linux else [],
|
||||
"include_files": [], # broken in cx 6.14.0, we use more special sauce now
|
||||
"include_msvcr": False,
|
||||
"replace_paths": [("*", "")],
|
||||
"replace_paths": ["*."],
|
||||
"optimize": 1,
|
||||
"build_exe": buildfolder,
|
||||
"extra_data": extra_data,
|
||||
"extra_libs": extra_libs,
|
||||
"bin_includes": ["libffi.so", "libcrypt.so"] if is_linux else []
|
||||
},
|
||||
"bdist_appimage": {
|
||||
|
||||
159
test/TestBase.py
159
test/TestBase.py
@@ -1,12 +1,17 @@
|
||||
import unittest
|
||||
import pathlib
|
||||
import typing
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
|
||||
import Utils
|
||||
from test.general import gen_steps
|
||||
from worlds import AutoWorld
|
||||
from worlds.AutoWorld import call_all
|
||||
|
||||
file_path = pathlib.Path(__file__).parent.parent
|
||||
Utils.local_path.cached_path = file_path
|
||||
|
||||
from BaseClasses import MultiWorld, CollectionState, ItemClassification
|
||||
from BaseClasses import MultiWorld, CollectionState, ItemClassification, Item
|
||||
from worlds.alttp.Items import ItemFactory
|
||||
|
||||
|
||||
@@ -92,3 +97,153 @@ class TestBase(unittest.TestCase):
|
||||
new_items.remove(missing_item)
|
||||
items = ItemFactory(new_items, 1)
|
||||
return self.get_state(items)
|
||||
|
||||
|
||||
class WorldTestBase(unittest.TestCase):
|
||||
options: typing.Dict[str, typing.Any] = {}
|
||||
multiworld: MultiWorld
|
||||
|
||||
game: typing.ClassVar[str] # define game name in subclass, example "Secret of Evermore"
|
||||
auto_construct: typing.ClassVar[bool] = True
|
||||
""" automatically set up a world for each test in this class """
|
||||
|
||||
def setUp(self) -> None:
|
||||
if self.auto_construct:
|
||||
self.world_setup()
|
||||
|
||||
def world_setup(self, seed: typing.Optional[int] = None) -> None:
|
||||
if type(self) is WorldTestBase or \
|
||||
(hasattr(WorldTestBase, self._testMethodName)
|
||||
and not self.run_default_tests and
|
||||
getattr(self, self._testMethodName).__code__ is
|
||||
getattr(WorldTestBase, self._testMethodName, None).__code__):
|
||||
return # setUp gets called for tests defined in the base class. We skip world_setup here.
|
||||
if not hasattr(self, "game"):
|
||||
raise NotImplementedError("didn't define game name")
|
||||
self.multiworld = MultiWorld(1)
|
||||
self.multiworld.game[1] = self.game
|
||||
self.multiworld.player_name = {1: "Tester"}
|
||||
self.multiworld.set_seed(seed)
|
||||
args = Namespace()
|
||||
for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].option_definitions.items():
|
||||
setattr(args, name, {
|
||||
1: option.from_any(self.options.get(name, getattr(option, "default")))
|
||||
})
|
||||
self.multiworld.set_options(args)
|
||||
self.multiworld.set_default_common_options()
|
||||
for step in gen_steps:
|
||||
call_all(self.multiworld, step)
|
||||
|
||||
# methods that can be called within tests
|
||||
def collect_all_but(self, item_names: typing.Union[str, typing.Iterable[str]]) -> None:
|
||||
"""Collects all pre-placed items and items in the multiworld itempool except those provided"""
|
||||
if isinstance(item_names, str):
|
||||
item_names = (item_names,)
|
||||
for item in self.multiworld.get_items():
|
||||
if item.name not in item_names:
|
||||
self.multiworld.state.collect(item)
|
||||
|
||||
def get_item_by_name(self, item_name: str) -> Item:
|
||||
"""Returns the first item found in placed items, or in the itempool with the matching name"""
|
||||
for item in self.multiworld.get_items():
|
||||
if item.name == item_name:
|
||||
return item
|
||||
raise ValueError("No such item")
|
||||
|
||||
def get_items_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]:
|
||||
"""Returns actual items from the itempool that match the provided name(s)"""
|
||||
if isinstance(item_names, str):
|
||||
item_names = (item_names,)
|
||||
return [item for item in self.multiworld.itempool if item.name in item_names]
|
||||
|
||||
def collect_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]:
|
||||
""" collect all of the items in the item pool that have the given names """
|
||||
items = self.get_items_by_name(item_names)
|
||||
self.collect(items)
|
||||
return items
|
||||
|
||||
def collect(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None:
|
||||
"""Collects the provided item(s) into state"""
|
||||
if isinstance(items, Item):
|
||||
items = (items,)
|
||||
for item in items:
|
||||
self.multiworld.state.collect(item)
|
||||
|
||||
def remove(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None:
|
||||
"""Removes the provided item(s) from state"""
|
||||
if isinstance(items, Item):
|
||||
items = (items,)
|
||||
for item in items:
|
||||
if item.location and item.location.event and item.location in self.multiworld.state.events:
|
||||
self.multiworld.state.events.remove(item.location)
|
||||
self.multiworld.state.remove(item)
|
||||
|
||||
def can_reach_location(self, location: str) -> bool:
|
||||
"""Determines if the current state can reach the provide location name"""
|
||||
return self.multiworld.state.can_reach(location, "Location", 1)
|
||||
|
||||
def can_reach_entrance(self, entrance: str) -> bool:
|
||||
"""Determines if the current state can reach the provided entrance name"""
|
||||
return self.multiworld.state.can_reach(entrance, "Entrance", 1)
|
||||
|
||||
def count(self, item_name: str) -> int:
|
||||
"""Returns the amount of an item currently in state"""
|
||||
return self.multiworld.state.count(item_name, 1)
|
||||
|
||||
def assertAccessDependency(self,
|
||||
locations: typing.List[str],
|
||||
possible_items: typing.Iterable[typing.Iterable[str]]) -> None:
|
||||
"""Asserts that the provided locations can't be reached without the listed items but can be reached with any
|
||||
one of the provided combinations"""
|
||||
all_items = [item_name for item_names in possible_items for item_name in item_names]
|
||||
|
||||
self.collect_all_but(all_items)
|
||||
for location in self.multiworld.get_locations():
|
||||
self.assertEqual(self.multiworld.state.can_reach(location), location.name not in locations)
|
||||
for item_names in possible_items:
|
||||
items = self.collect_by_name(item_names)
|
||||
for location in locations:
|
||||
self.assertTrue(self.can_reach_location(location))
|
||||
self.remove(items)
|
||||
|
||||
def assertBeatable(self, beatable: bool):
|
||||
"""Asserts that the game can be beaten with the current state"""
|
||||
self.assertEqual(self.multiworld.can_beat_game(self.multiworld.state), beatable)
|
||||
|
||||
# following tests are automatically run
|
||||
@property
|
||||
def run_default_tests(self) -> bool:
|
||||
"""Not possible or identical to the base test that's always being run already"""
|
||||
return (self.options
|
||||
or self.setUp.__code__ is not WorldTestBase.setUp.__code__
|
||||
or self.world_setup.__code__ is not WorldTestBase.world_setup.__code__)
|
||||
|
||||
@property
|
||||
def constructed(self) -> bool:
|
||||
"""A multiworld has been constructed by this point"""
|
||||
return hasattr(self, "game") and hasattr(self, "multiworld")
|
||||
|
||||
def testAllStateCanReachEverything(self):
|
||||
"""Ensure all state can reach everything and complete the game with the defined options"""
|
||||
if not (self.run_default_tests and self.constructed):
|
||||
return
|
||||
with self.subTest("Game", game=self.game):
|
||||
excluded = self.multiworld.exclude_locations[1].value
|
||||
state = self.multiworld.get_all_state(False)
|
||||
for location in self.multiworld.get_locations():
|
||||
if location.name not in excluded:
|
||||
with self.subTest("Location should be reached", location=location):
|
||||
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
|
||||
with self.subTest("Beatable"):
|
||||
self.multiworld.state = state
|
||||
self.assertBeatable(True)
|
||||
|
||||
def testEmptyStateCanReachSomething(self):
|
||||
"""Ensure empty state can reach at least one location with the defined options"""
|
||||
if not (self.run_default_tests and self.constructed):
|
||||
return
|
||||
with self.subTest("Game", game=self.game):
|
||||
state = CollectionState(self.multiworld)
|
||||
locations = self.multiworld.get_reachable_locations(state, 1)
|
||||
self.assertGreater(len(locations), 0,
|
||||
"Need to be able to reach at least one location to get started.")
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import warnings
|
||||
warnings.simplefilter("always")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import unittest
|
||||
from worlds.AutoWorld import World
|
||||
from Fill import FillError, balance_multiworld_progression, fill_restrictive, \
|
||||
distribute_early_items, distribute_items_restrictive
|
||||
from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, RegionType, Item, Location, \
|
||||
from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, Item, Location, \
|
||||
ItemClassification
|
||||
from worlds.generic.Rules import CollectionRule, add_item_rule, locality_rules, set_rule
|
||||
|
||||
@@ -17,8 +17,7 @@ def generate_multi_world(players: int = 1) -> MultiWorld:
|
||||
multi_world.game[player_id] = f"Game {player_id}"
|
||||
multi_world.worlds[player_id] = world
|
||||
multi_world.player_name[player_id] = "Test Player " + str(player_id)
|
||||
region = Region("Menu", RegionType.Generic,
|
||||
"Menu Region Hint", player_id, multi_world)
|
||||
region = Region("Menu", player_id, multi_world, "Menu Region Hint")
|
||||
multi_world.regions.append(region)
|
||||
|
||||
multi_world.set_seed(0)
|
||||
@@ -48,8 +47,7 @@ class PlayerDefinition(object):
|
||||
def generate_region(self, parent: Region, size: int, access_rule: CollectionRule = lambda state: True) -> Region:
|
||||
region_tag = "_region" + str(len(self.regions))
|
||||
region_name = "player" + str(self.id) + region_tag
|
||||
region = Region("player" + str(self.id) + region_tag, RegionType.Generic,
|
||||
"Region Hint", self.id, self.multiworld)
|
||||
region = Region("player" + str(self.id) + region_tag, self.id, self.multiworld)
|
||||
self.locations += generate_locations(size, self.id, None, region, region_tag)
|
||||
|
||||
entrance = Entrance(self.id, region_name + "_entrance", parent)
|
||||
|
||||
25
test/general/TestHostYAML.py
Normal file
25
test/general/TestHostYAML.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import unittest
|
||||
|
||||
import Utils
|
||||
|
||||
|
||||
class TestIDs(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
with open(Utils.local_path("host.yaml")) as f:
|
||||
cls.yaml_options = Utils.parse_yaml(f.read())
|
||||
|
||||
def testUtilsHasHost(self):
|
||||
for option_key, option_set in Utils.get_default_options().items():
|
||||
with self.subTest(option_key):
|
||||
self.assertIn(option_key, self.yaml_options)
|
||||
for sub_option_key in option_set:
|
||||
self.assertIn(sub_option_key, self.yaml_options[option_key])
|
||||
|
||||
def testHostHasUtils(self):
|
||||
utils_options = Utils.get_default_options()
|
||||
for option_key, option_set in self.yaml_options.items():
|
||||
with self.subTest(option_key):
|
||||
self.assertIn(option_key, utils_options)
|
||||
for sub_option_key in option_set:
|
||||
self.assertIn(sub_option_key, utils_options[option_key])
|
||||
@@ -1,14 +1,33 @@
|
||||
import unittest
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
from . import setup_default_world
|
||||
from . import setup_solo_multiworld
|
||||
|
||||
|
||||
class TestImplemented(unittest.TestCase):
|
||||
def testCompletionCondition(self):
|
||||
"""Ensure a completion condition is set that has requirements."""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden and gamename not in {"ArchipIDLE", "Final Fantasy", "Sudoku"}:
|
||||
with self.subTest(gamename):
|
||||
world = setup_default_world(world_type)
|
||||
self.assertFalse(world.completion_condition[1](world.state))
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden and game_name not in {"Sudoku"}:
|
||||
with self.subTest(game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
self.assertFalse(multiworld.completion_condition[1](multiworld.state))
|
||||
|
||||
def testEntranceParents(self):
|
||||
"""Tests that the parents of created Entrances match the exiting Region."""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
with self.subTest(game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
for region in multiworld.regions:
|
||||
for exit in region.exits:
|
||||
self.assertEqual(exit.parent_region, region)
|
||||
|
||||
def testStageMethods(self):
|
||||
"""Tests that worlds don't try to implement certain steps that are only ever called as stage."""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
with self.subTest(game_name):
|
||||
for method in ("assert_generate",):
|
||||
self.assertFalse(hasattr(world_type, method),
|
||||
f"{method} must be implemented as a @classmethod named stage_{method}.")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user