diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 000000000000..17a60ad125f7
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,5 @@
+[report]
+exclude_lines =
+ pragma: no cover
+ if TYPE_CHECKING:
+ if typing.TYPE_CHECKING:
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000000..982e411032c6
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,210 @@
+.git
+.github
+.run
+docs
+test
+typings
+*Client.py
+
+.idea
+.vscode
+
+*_Spoiler.txt
+*.bmbp
+*.apbp
+*.apl2ac
+*.apm3
+*.apmc
+*.apz5
+*.aptloz
+*.apemerald
+*.pyc
+*.pyd
+*.sfc
+*.z64
+*.n64
+*.nes
+*.smc
+*.sms
+*.gb
+*.gbc
+*.gba
+*.wixobj
+*.lck
+*.db3
+*multidata
+*multisave
+*.archipelago
+*.apsave
+*.BIN
+*.puml
+
+setups
+build
+bundle/components.wxs
+dist
+/prof/
+README.html
+.vs/
+EnemizerCLI/
+/Players/
+/SNI/
+/sni-*/
+/appimagetool*
+/host.yaml
+/options.yaml
+/config.yaml
+/logs/
+_persistent_storage.yaml
+mystery_result_*.yaml
+*-errors.txt
+success.txt
+output/
+Output Logs/
+/factorio/
+/Minecraft Forge Server/
+/WebHostLib/static/generated
+/freeze_requirements.txt
+/Archipelago.zip
+/setup.ini
+/installdelete.iss
+/data/user.kv
+/datapackage
+/custom_worlds
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+*.dll
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+installer.log
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# vim editor
+*.swp
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv*
+env/
+venv/
+/venv*/
+ENV/
+env.bak/
+venv.bak/
+*.code-workspace
+shell.nix
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# Cython intermediates
+_speedups.c
+_speedups.cpp
+_speedups.html
+
+# minecraft server stuff
+jdk*/
+minecraft*/
+minecraft_versions.json
+!worlds/minecraft/
+
+# pyenv
+.python-version
+
+#undertale stuff
+/Undertale/
+
+# OS General Files
+.DS_Store
+.AppleDouble
+.LSOverride
+Thumbs.db
+[Dd]esktop.ini
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000000..5ab537933405
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+worlds/blasphemous/region_data.py linguist-generated=true
+worlds/yachtdice/YachtWeights.py linguist-generated=true
diff --git a/.github/labeler.yml b/.github/labeler.yml
new file mode 100644
index 000000000000..d0aa61c8cfc0
--- /dev/null
+++ b/.github/labeler.yml
@@ -0,0 +1,30 @@
+'is: documentation':
+- changed-files:
+ - all-globs-to-all-files: '{**/docs/**,**/README.md}'
+
+'affects: webhost':
+- changed-files:
+ - all-globs-to-any-file: 'WebHost.py'
+ - all-globs-to-any-file: 'WebHostLib/**/*'
+
+'affects: core':
+- changed-files:
+ - all-globs-to-any-file:
+ - '!*Client.py'
+ - '!README.md'
+ - '!LICENSE'
+ - '!*.yml'
+ - '!.gitignore'
+ - '!**/docs/**'
+ - '!typings/kivy/**'
+ - '!test/**'
+ - '!data/**'
+ - '!.run/**'
+ - '!.github/**'
+ - '!worlds/**'
+ - '!WebHost.py'
+ - '!WebHostLib/**'
+ - any-glob-to-any-file: # exceptions to the above rules of "stuff that isn't core"
+ - 'worlds/generic/**/*.py'
+ - 'worlds/*.py'
+ - 'CommonClient.py'
diff --git a/.github/pyright-config.json b/.github/pyright-config.json
new file mode 100644
index 000000000000..64a46d80cceb
--- /dev/null
+++ b/.github/pyright-config.json
@@ -0,0 +1,40 @@
+{
+ "include": [
+ "../BizHawkClient.py",
+ "../Patch.py",
+ "../test/param.py",
+ "../test/general/test_groups.py",
+ "../test/general/test_helpers.py",
+ "../test/general/test_memory.py",
+ "../test/general/test_names.py",
+ "../test/multiworld/__init__.py",
+ "../test/multiworld/test_multiworlds.py",
+ "../test/netutils/__init__.py",
+ "../test/programs/__init__.py",
+ "../test/programs/test_multi_server.py",
+ "../test/utils/__init__.py",
+ "../test/webhost/test_descriptions.py",
+ "../worlds/AutoSNIClient.py",
+ "type_check.py"
+ ],
+
+ "exclude": [
+ "**/__pycache__"
+ ],
+
+ "stubPath": "../typings",
+
+ "typeCheckingMode": "strict",
+ "reportImplicitOverride": "error",
+ "reportMissingImports": true,
+ "reportMissingTypeStubs": true,
+
+ "pythonVersion": "3.11",
+ "pythonPlatform": "Windows",
+
+ "executionEnvironments": [
+ {
+ "root": ".."
+ }
+ ]
+}
diff --git a/.github/type_check.py b/.github/type_check.py
new file mode 100644
index 000000000000..90d41722c9a5
--- /dev/null
+++ b/.github/type_check.py
@@ -0,0 +1,15 @@
+from pathlib import Path
+import subprocess
+
+config = Path(__file__).parent / "pyright-config.json"
+
+command = ("pyright", "-p", str(config))
+print(" ".join(command))
+
+try:
+ result = subprocess.run(command)
+except FileNotFoundError as e:
+ print(f"{e} - Is pyright installed?")
+ exit(1)
+
+exit(result.returncode)
diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml
index ba2660809aaa..862a050c517e 100644
--- a/.github/workflows/analyze-modified-files.yml
+++ b/.github/workflows/analyze-modified-files.yml
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: "Determine modified files (pull_request)"
if: github.event_name == 'pull_request'
@@ -50,10 +50,10 @@ jobs:
run: |
echo "diff=." >> $GITHUB_ENV
- - uses: actions/setup-python@v4
+ - uses: actions/setup-python@v5
if: env.diff != ''
with:
- python-version: 3.8
+ python-version: '3.11'
- name: "Install dependencies"
if: env.diff != ''
@@ -65,13 +65,13 @@ jobs:
continue-on-error: false
if: env.diff != '' && matrix.task == 'flake8'
run: |
- flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
+ flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }}
- name: "flake8: Lint modified files"
continue-on-error: true
if: env.diff != '' && matrix.task == 'flake8'
run: |
- flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
+ flake8 --count --max-complexity=14 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
- name: "mypy: Type check modified files"
continue-on-error: true
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a40084b9ab72..9c0cd14f8b27 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -8,36 +8,57 @@ on:
- '.github/workflows/build.yml'
- 'setup.py'
- 'requirements.txt'
+ - '*.iss'
+ - 'worlds/*/archipelago.json'
pull_request:
paths:
- '.github/workflows/build.yml'
- 'setup.py'
- 'requirements.txt'
+ - '*.iss'
+ - 'worlds/*/archipelago.json'
workflow_dispatch:
env:
ENEMIZER_VERSION: 7.1
- APPIMAGETOOL_VERSION: 13
+ # NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
+ # we check the sha256 and require manual intervention if it was updated.
+ APPIMAGE_FORK: 'PopTracker'
+ APPIMAGETOOL_VERSION: 'r-2025-11-18'
+ APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
+ APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
+ APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
+
+permissions: # permissions required for attestation
+ id-token: 'write'
+ attestations: 'write'
jobs:
# build-release-macos: # LF volunteer
- build-win-py38: # RCs will still be built and signed by hand
+ build-win: # RCs and releases may still be built and signed by hand
runs-on: windows-latest
steps:
- - uses: actions/checkout@v3
+ # - copy code below to release.yml -
+ - uses: actions/checkout@v4
- name: Install python
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
- python-version: '3.8'
+ python-version: '~3.12.7'
+ check-latest: true
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
+ choco install innosetup --version=6.2.2 --allow-downgrade
- name: Build
run: |
python -m pip install --upgrade pip
python setup.py build_exe --yes
+ if ( $? -eq $false ) {
+ Write-Error "setup.py failed!"
+ exit 1
+ }
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME"
@@ -46,34 +67,88 @@ jobs:
cd build
Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
+ Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
+ - name: Build Setup
+ run: |
+ & "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
+ if ( $? -eq $false ) {
+ Write-Error "Building setup failed!"
+ exit 1
+ }
+ $contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
+ $SETUP_NAME=$contents[0].Name
+ echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
+ # - copy code above to release.yml -
+ - name: Attest Build
+ if: ${{ github.event_name == 'workflow_dispatch' }}
+ uses: actions/attest-build-provenance@v2
+ with:
+ subject-path: |
+ build/exe.*/ArchipelagoLauncher.exe
+ build/exe.*/ArchipelagoLauncherDebug.exe
+ build/exe.*/ArchipelagoGenerate.exe
+ build/exe.*/ArchipelagoServer.exe
+ dist/${{ env.ZIP_NAME }}
+ setups/${{ env.SETUP_NAME }}
+ - name: Check build loads expected worlds
+ shell: bash
+ run: |
+ cd build/exe*
+ mv Players/Templates/meta.yaml .
+ ls -1 Players/Templates | sort > setup-player-templates.txt
+ rm -R Players/Templates
+ timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
+ ls -1 Players/Templates | sort > generated-player-templates.txt
+ cmp setup-player-templates.txt generated-player-templates.txt \
+ || diff setup-player-templates.txt generated-player-templates.txt
+ mv meta.yaml Players/Templates/
+ - name: Test Generate
+ shell: bash
+ run: |
+ cd build/exe*
+ cp Players/Templates/VVVVVV.yaml Players/
+ timeout 30 ./ArchipelagoGenerate
- name: Store 7z
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
+ compression-level: 0 # .7z is incompressible by zip
+ if-no-files-found: error
+ retention-days: 7 # keep for 7 days, should be enough
+ - name: Store Setup
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ env.SETUP_NAME }}
+ path: setups/${{ env.SETUP_NAME }}
+ if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
- build-ubuntu2004:
- runs-on: ubuntu-20.04
+ build-ubuntu2204:
+ runs-on: ubuntu-22.04
steps:
# - copy code below to release.yml -
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- 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@v4
+ uses: actions/setup-python@v5
with:
- python-version: '3.11'
+ python-version: '~3.12.7'
+ check-latest: true
- name: Install build-time dependencies
run: |
- echo "PYTHON=python3.11" >> $GITHUB_ENV
- wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
+ echo "PYTHON=python3.12" >> $GITHUB_ENV
+ wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
+ echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
+ wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
+ echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
- echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
+ echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
@@ -85,29 +160,60 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
- "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
+ "${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
- (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
+ (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
+ - name: Attest Build
+ if: ${{ github.event_name == 'workflow_dispatch' }}
+ uses: actions/attest-build-provenance@v2
+ with:
+ subject-path: |
+ build/exe.*/ArchipelagoLauncher
+ build/exe.*/ArchipelagoGenerate
+ build/exe.*/ArchipelagoServer
+ dist/${{ env.APPIMAGE_NAME }}*
+ dist/${{ env.TAR_NAME }}
- name: Build Again
run: |
source venv/bin/activate
python setup.py build_exe --yes
+ - name: Check build loads expected worlds
+ shell: bash
+ run: |
+ cd build/exe*
+ mv Players/Templates/meta.yaml .
+ ls -1 Players/Templates | sort > setup-player-templates.txt
+ rm -R Players/Templates
+ timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
+ ls -1 Players/Templates | sort > generated-player-templates.txt
+ cmp setup-player-templates.txt generated-player-templates.txt \
+ || diff setup-player-templates.txt generated-player-templates.txt
+ mv meta.yaml Players/Templates/
+ - name: Test Generate
+ shell: bash
+ run: |
+ cd build/exe*
+ cp Players/Templates/VVVVVV.yaml Players/
+ timeout 30 ./ArchipelagoGenerate
- name: Store AppImage
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
+ if-no-files-found: error
retention-days: 7
- name: Store .tar.gz
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}
+ compression-level: 0 # .gz is incompressible by zip
+ if-no-files-found: error
retention-days: 7
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 6aeb477a22d7..3abbb5f6449f 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -43,11 +43,11 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -58,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@v2
+ uses: github/codeql-action/autobuild@v3
# âšī¸ Command-line programs to run using the OS shell.
# đ https://git.io/JvXDl
@@ -72,4 +72,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/ctest.yml b/.github/workflows/ctest.yml
new file mode 100644
index 000000000000..610f6d747779
--- /dev/null
+++ b/.github/workflows/ctest.yml
@@ -0,0 +1,54 @@
+# Run CMake / CTest C++ unit tests
+
+name: ctest
+
+on:
+ push:
+ paths:
+ - '**.cc?'
+ - '**.cpp'
+ - '**.cxx'
+ - '**.hh?'
+ - '**.hpp'
+ - '**.hxx'
+ - '**/CMakeLists.txt'
+ - '.github/workflows/ctest.yml'
+ pull_request:
+ paths:
+ - '**.cc?'
+ - '**.cpp'
+ - '**.cxx'
+ - '**.hh?'
+ - '**.hpp'
+ - '**.hxx'
+ - '**/CMakeLists.txt'
+ - '.github/workflows/ctest.yml'
+
+jobs:
+ ctest:
+ runs-on: ${{ matrix.os }}
+ name: Test C++ ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
+ if: startsWith(matrix.os,'windows')
+ - uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
+ with:
+ build-type: 'Release'
+ - name: Build tests
+ run: |
+ cd test/cpp
+ mkdir build
+ cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
+ cmake --build build/ --config Release
+ ls
+ - name: Run tests
+ run: |
+ cd test/cpp
+ ctest --test-dir build/ -C Release --output-on-failure
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 000000000000..0061dd15b000
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,154 @@
+name: Build and Publish Docker Images
+
+on:
+ push:
+ paths:
+ - "**"
+ - "!docs/**"
+ - "!deploy/**"
+ - "!setup.py"
+ - "!.gitignore"
+ - "!.github/workflows/**"
+ - ".github/workflows/docker.yml"
+ branches:
+ - "main"
+ tags:
+ - "v?[0-9]+.[0-9]+.[0-9]*"
+ workflow_dispatch:
+
+env:
+ REGISTRY: ghcr.io
+
+jobs:
+ prepare:
+ runs-on: ubuntu-latest
+ outputs:
+ image-name: ${{ steps.image.outputs.name }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ package-name: ${{ steps.package.outputs.name }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set lowercase image name
+ id: image
+ run: |
+ echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
+
+ - name: Set package name
+ id: package
+ run: |
+ echo "name=$(basename ${GITHUB_REPOSITORY,,})" >> $GITHUB_OUTPUT
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}
+ tags: |
+ type=ref,event=branch,enable={{is_not_default_branch}}
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=raw,value=nightly,enable={{is_default_branch}}
+
+ - name: Compute final tags
+ id: final-tags
+ run: |
+ readarray -t tags <<< "${{ steps.meta.outputs.tags }}"
+
+ if [[ "${{ github.ref_type }}" == "tag" ]]; then
+ tag="${{ github.ref_name }}"
+ if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ full_latest="${{ env.REGISTRY }}/${{ steps.image.outputs.name }}:latest"
+ # Check if latest is already in tags to avoid duplicates
+ if ! printf '%s\n' "${tags[@]}" | grep -q "^$full_latest$"; then
+ tags+=("$full_latest")
+ fi
+ fi
+ fi
+
+ # Set multiline output
+ echo "tags<> $GITHUB_OUTPUT
+ printf '%s\n' "${tags[@]}" >> $GITHUB_OUTPUT
+ echo "EOF" >> $GITHUB_OUTPUT
+
+ build:
+ needs: prepare
+ runs-on: ${{ matrix.runner }}
+ permissions:
+ contents: read
+ packages: write
+ strategy:
+ matrix:
+ include:
+ - platform: amd64
+ runner: ubuntu-latest
+ suffix: amd64
+ cache-scope: amd64
+ - platform: arm64
+ runner: ubuntu-24.04-arm
+ suffix: arm64
+ cache-scope: arm64
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Compute suffixed tags
+ id: tags
+ run: |
+ readarray -t tags <<< "${{ needs.prepare.outputs.tags }}"
+ suffixed=()
+ for t in "${tags[@]}"; do
+ suffixed+=("$t-${{ matrix.suffix }}")
+ done
+ echo "tags=$(IFS=','; echo "${suffixed[*]}")" >> $GITHUB_OUTPUT
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./Dockerfile
+ platforms: linux/${{ matrix.platform }}
+ push: true
+ tags: ${{ steps.tags.outputs.tags }}
+ labels: ${{ needs.prepare.outputs.labels }}
+ cache-from: type=gha,scope=${{ matrix.cache-scope }}
+ cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
+ provenance: false
+
+ manifest:
+ needs: [prepare, build]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create and push multi-arch manifest
+ run: |
+ readarray -t tag_array <<< "${{ needs.prepare.outputs.tags }}"
+
+ for tag in "${tag_array[@]}"; do
+ docker manifest create "$tag" \
+ "$tag-amd64" \
+ "$tag-arm64"
+
+ docker manifest push "$tag"
+ done
diff --git a/.github/workflows/label-pull-requests.yml b/.github/workflows/label-pull-requests.yml
new file mode 100644
index 000000000000..1675c942bddb
--- /dev/null
+++ b/.github/workflows/label-pull-requests.yml
@@ -0,0 +1,47 @@
+name: Label Pull Request
+on:
+ pull_request_target:
+ types: ['opened', 'reopened', 'synchronize', 'ready_for_review', 'converted_to_draft', 'closed']
+ branches: ['main']
+permissions:
+ contents: read
+ pull-requests: write
+env:
+ GH_REPO: ${{ github.repository }}
+
+jobs:
+ labeler:
+ name: 'Apply content-based labels'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/labeler@v5
+ with:
+ sync-labels: false
+ peer_review:
+ name: 'Apply peer review label'
+ needs: labeler
+ if: >-
+ (github.event.action == 'opened' || github.event.action == 'reopened' ||
+ github.event.action == 'ready_for_review') && !github.event.pull_request.draft
+ runs-on: ubuntu-latest
+ steps:
+ - name: 'Add label'
+ run: "gh pr edit \"$PR_URL\" --add-label 'waiting-on: peer-review'"
+ env:
+ PR_URL: ${{ github.event.pull_request.html_url }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ unblock_draft_prs:
+ name: 'Remove waiting-on labels'
+ needs: labeler
+ if: github.event.action == 'converted_to_draft' || github.event.action == 'closed'
+ runs-on: ubuntu-latest
+ steps:
+ - name: 'Remove labels'
+ run: |-
+ gh pr edit "$PR_URL" --remove-label 'waiting-on: peer-review' \
+ --remove-label 'waiting-on: core-review' \
+ --remove-label 'waiting-on: world-maintainer' \
+ --remove-label 'waiting-on: author'
+ env:
+ PR_URL: ${{ github.event.pull_request.html_url }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cc68a88b7651..7f81e5750746 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,11 +5,22 @@ name: Release
on:
push:
tags:
- - '*.*.*'
+ - 'v?[0-9]+.[0-9]+.[0-9]*'
env:
ENEMIZER_VERSION: 7.1
- APPIMAGETOOL_VERSION: 13
+ # NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
+ # we check the sha256 and require manual intervention if it was updated.
+ APPIMAGE_FORK: 'PopTracker'
+ APPIMAGETOOL_VERSION: 'r-2025-11-18'
+ APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
+ APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
+ APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
+
+permissions: # permissions required for attestation
+ id-token: 'write'
+ attestations: 'write'
+ contents: 'write' # additionally required for release
jobs:
create-release:
@@ -18,7 +29,7 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
- name: Create Release
- uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
+ uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # don't publish right away, especially since windows build is added by hand
prerelease: false
@@ -26,32 +37,104 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- # build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer
- build-release-ubuntu2004:
- runs-on: ubuntu-20.04
+ build-release-win:
+ runs-on: windows-latest
+ if: ${{ true }} # change to false to skip if release is built by hand
+ needs: create-release
+ steps:
+ - name: Set env
+ shell: bash
+ run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
+ # - code below copied from build.yml -
+ - uses: actions/checkout@v4
+ - name: Install python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '~3.12.7'
+ check-latest: true
+ - name: Download run-time dependencies
+ run: |
+ Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
+ Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
+ choco install innosetup --version=6.2.2 --allow-downgrade
+ - name: Build
+ run: |
+ python -m pip install --upgrade pip
+ python setup.py build_exe --yes
+ if ( $? -eq $false ) {
+ Write-Error "setup.py failed!"
+ exit 1
+ }
+ $NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
+ $ZIP_NAME="Archipelago_$NAME.7z"
+ echo "$NAME -> $ZIP_NAME"
+ echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
+ New-Item -Path dist -ItemType Directory -Force
+ cd build
+ Rename-Item "exe.$NAME" Archipelago
+ 7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
+ Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
+ - name: Build Setup
+ run: |
+ & "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
+ if ( $? -eq $false ) {
+ Write-Error "Building setup failed!"
+ exit 1
+ }
+ $contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
+ $SETUP_NAME=$contents[0].Name
+ echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
+ # - code above copied from build.yml -
+ - name: Attest Build
+ uses: actions/attest-build-provenance@v2
+ with:
+ subject-path: |
+ build/exe.*/ArchipelagoLauncher.exe
+ build/exe.*/ArchipelagoLauncherDebug.exe
+ build/exe.*/ArchipelagoGenerate.exe
+ build/exe.*/ArchipelagoServer.exe
+ setups/*
+ - name: Add to Release
+ uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
+ with:
+ draft: true # see above
+ prerelease: false
+ name: Archipelago ${{ env.RELEASE_VERSION }}
+ files: |
+ setups/*
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ build-release-ubuntu2204:
+ runs-on: ubuntu-22.04
+ needs: create-release
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- 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@v4
+ uses: actions/setup-python@v5
with:
- python-version: '3.11'
+ python-version: '~3.12.7'
+ check-latest: true
- name: Install build-time dependencies
run: |
- echo "PYTHON=python3.11" >> $GITHUB_ENV
- wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
+ echo "PYTHON=python3.12" >> $GITHUB_ENV
+ wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
+ echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
+ wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
+ echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
- echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
+ echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
@@ -63,18 +146,26 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
- "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
+ "${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
- (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
+ (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
+ - name: Attest Build
+ uses: actions/attest-build-provenance@v2
+ with:
+ subject-path: |
+ build/exe.*/ArchipelagoLauncher
+ build/exe.*/ArchipelagoGenerate
+ build/exe.*/ArchipelagoServer
+ dist/*
- name: Add to Release
- uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
+ uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # see above
prerelease: false
diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml
new file mode 100644
index 000000000000..ac842070625f
--- /dev/null
+++ b/.github/workflows/scan-build.yml
@@ -0,0 +1,65 @@
+name: Native Code Static Analysis
+
+on:
+ push:
+ paths:
+ - '**.c'
+ - '**.cc'
+ - '**.cpp'
+ - '**.cxx'
+ - '**.h'
+ - '**.hh'
+ - '**.hpp'
+ - '**.pyx'
+ - 'setup.py'
+ - 'requirements.txt'
+ - '.github/workflows/scan-build.yml'
+ pull_request:
+ paths:
+ - '**.c'
+ - '**.cc'
+ - '**.cpp'
+ - '**.cxx'
+ - '**.h'
+ - '**.hh'
+ - '**.hpp'
+ - '**.pyx'
+ - 'setup.py'
+ - 'requirements.txt'
+ - '.github/workflows/scan-build.yml'
+
+jobs:
+ scan-build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ - name: Install newer Clang
+ run: |
+ wget https://apt.llvm.org/llvm.sh
+ chmod +x ./llvm.sh
+ sudo ./llvm.sh 19
+ - name: Install scan-build command
+ run: |
+ sudo apt install clang-tools-19
+ - name: Get a recent python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ - name: Install dependencies
+ run: |
+ python -m venv venv
+ source venv/bin/activate
+ python -m pip install --upgrade pip -r requirements.txt
+ - name: scan-build
+ run: |
+ source venv/bin/activate
+ scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
+ - name: Store report
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: scan-build-reports
+ path: scan-build-reports
diff --git a/.github/workflows/strict-type-check.yml b/.github/workflows/strict-type-check.yml
new file mode 100644
index 000000000000..2ccdad8d11af
--- /dev/null
+++ b/.github/workflows/strict-type-check.yml
@@ -0,0 +1,33 @@
+name: type check
+
+on:
+ pull_request:
+ paths:
+ - "**.py"
+ - ".github/pyright-config.json"
+ - ".github/workflows/strict-type-check.yml"
+ - "**.pyi"
+ push:
+ paths:
+ - "**.py"
+ - ".github/pyright-config.json"
+ - ".github/workflows/strict-type-check.yml"
+ - "**.pyi"
+
+jobs:
+ pyright:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+
+ - name: "Install dependencies"
+ run: |
+ python -m pip install --upgrade pip pyright==1.1.392.post0
+ python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
+
+ - name: "pyright: strict check on specific files"
+ run: python .github/type_check.py
diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml
index d24c55b49ac2..b08b389005ec 100644
--- a/.github/workflows/unittests.yml
+++ b/.github/workflows/unittests.yml
@@ -8,23 +8,29 @@ on:
paths:
- '**'
- '!docs/**'
+ - '!deploy/**'
- '!setup.py'
+ - '!Dockerfile'
- '!*.iss'
- '!.gitignore'
+ - '!.dockerignore'
- '!.github/workflows/**'
- '.github/workflows/unittests.yml'
pull_request:
paths:
- '**'
- '!docs/**'
+ - '!deploy/**'
- '!setup.py'
+ - '!Dockerfile'
- '!*.iss'
- '!.gitignore'
+ - '!.dockerignore'
- '!.github/workflows/**'
- '.github/workflows/unittests.yml'
jobs:
- build:
+ unit:
runs-on: ${{ matrix.os }}
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
@@ -33,30 +39,58 @@ jobs:
matrix:
os: [ubuntu-latest]
python:
- - {version: '3.8'}
- - {version: '3.9'}
- - {version: '3.10'}
- - {version: '3.11'}
+ - {version: '3.11.2'} # Change to '3.11' around 2026-06-10
+ - {version: '3.12'}
+ - {version: '3.13'}
include:
- - python: {version: '3.8'} # win7 compat
+ - python: {version: '3.11'} # old compat
os: windows-latest
- - python: {version: '3.11'} # current
+ - python: {version: '3.13'} # current
os: windows-latest
- - python: {version: '3.11'} # current
+ - python: {version: '3.13'} # current
os: macos-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python.version }}
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install pytest pytest-subtests
+ pip install -r ci-requirements.txt
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
python Launcher.py --update_settings # make sure host.yaml exists for tests
- name: Unittests
run: |
- pytest
+ pytest -n auto
+
+ hosting:
+ runs-on: ${{ matrix.os }}
+ name: Test hosting with ${{ matrix.python.version }} on ${{ matrix.os }}
+
+ strategy:
+ matrix:
+ os:
+ - ubuntu-latest
+ python:
+ - {version: '3.13'} # current
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python ${{ matrix.python.version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python.version }}
+ - name: Install dependencies
+ run: |
+ python -m venv venv
+ source venv/bin/activate
+ python -m pip install --upgrade pip
+ python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
+ - name: Test hosting
+ run: |
+ source venv/bin/activate
+ export PYTHONPATH=$(pwd)
+ timeout 600 python test/hosting/__main__.py
diff --git a/.gitignore b/.gitignore
index 8e4cc86657a5..2028a0ad814e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,17 +4,21 @@
*_Spoiler.txt
*.bmbp
*.apbp
+*.apcivvi
*.apl2ac
*.apm3
*.apmc
*.apz5
*.aptloz
+*.aptww
+*.apemerald
*.pyc
*.pyd
*.sfc
*.z64
*.n64
*.nes
+*.smc
*.sms
*.gb
*.gbc
@@ -27,16 +31,20 @@
*.archipelago
*.apsave
*.BIN
+*.puml
setups
build
bundle/components.wxs
dist
+/prof/
README.html
.vs/
EnemizerCLI/
/Players/
/SNI/
+/sni-*/
+/appimagetool*
/host.yaml
/options.yaml
/config.yaml
@@ -48,7 +56,6 @@ success.txt
output/
Output Logs/
/factorio/
-/Minecraft Forge Server/
/WebHostLib/static/generated
/freeze_requirements.txt
/Archipelago.zip
@@ -56,6 +63,7 @@ Output Logs/
/installdelete.iss
/data/user.kv
/datapackage
+/custom_worlds
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -139,10 +147,11 @@ ipython_config.py
.venv*
env/
venv/
+/venv*/
ENV/
env.bak/
venv.bak/
-.code-workspace
+*.code-workspace
shell.nix
# Spyder project settings
@@ -170,14 +179,12 @@ dmypy.json
cython_debug/
# Cython intermediates
+_speedups.c
_speedups.cpp
_speedups.html
-# minecraft server stuff
-jdk*/
-minecraft*/
-minecraft_versions.json
-!worlds/minecraft/
+# directory that may hold sensitive information we don't want to push
+worlds/crosscode/data
# pyenv
.python-version
diff --git a/.run/Archipelago Unittests.run.xml b/.run/Archipelago Unittests.run.xml
new file mode 100644
index 000000000000..24fea0f73fec
--- /dev/null
+++ b/.run/Archipelago Unittests.run.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/Build APWorld.run.xml b/.run/Build APWorld.run.xml
new file mode 100644
index 000000000000..fe41bfe48945
--- /dev/null
+++ b/.run/Build APWorld.run.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AHITClient.py b/AHITClient.py
new file mode 100644
index 000000000000..edcbbd842e56
--- /dev/null
+++ b/AHITClient.py
@@ -0,0 +1,9 @@
+import sys
+from worlds.ahit.Client import launch
+import Utils
+import ModuleUpdate
+ModuleUpdate.update()
+
+if __name__ == "__main__":
+ Utils.init_logging("AHITClient", exception_logger="Client")
+ launch(*sys.argv[1:])
diff --git a/AdventureClient.py b/AdventureClient.py
index d2f4e734ac2c..b89b8f060009 100644
--- a/AdventureClient.py
+++ b/AdventureClient.py
@@ -11,6 +11,7 @@
import Utils
+from settings import get_settings
from NetUtils import ClientStatus
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
@@ -80,8 +81,8 @@ def __init__(self, server_address, password):
self.local_item_locations = {}
self.dragon_speed_info = {}
- options = Utils.get_options()
- self.display_msgs = options["adventure_options"]["display_msgs"]
+ options = get_settings().adventure_options
+ self.display_msgs = options.display_msgs
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -102,7 +103,7 @@ def _set_message(self, msg: str, msg_id: int):
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
- if Utils.get_options()["adventure_options"].get("death_link", False):
+ if get_settings().adventure_options.as_dict().get("death_link", False):
self.set_deathlink = True
async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo":
@@ -112,14 +113,15 @@ def on_package(self, cmd: str, args: dict):
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
- msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
+ msg = f"Received {', '.join([self.item_names.lookup_in_game(item.item) for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "Retrieved":
- self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
- if self.freeincarnates_used is None:
- self.freeincarnates_used = 0
- self.freeincarnates_used += self.freeincarnate_pending
- self.send_pending_freeincarnates()
+ if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
+ self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
+ if self.freeincarnates_used is None:
+ self.freeincarnates_used = 0
+ self.freeincarnates_used += self.freeincarnate_pending
+ self.send_pending_freeincarnates()
elif cmd == "SetReply":
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
self.freeincarnates_used = args["value"]
@@ -405,6 +407,7 @@ async def atari_sync_task(ctx: AdventureContext):
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.atari_status = CONNECTION_REFUSED_STATUS
+ await asyncio.sleep(1)
continue
except CancelledError:
pass
@@ -414,8 +417,9 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile):
- auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
- rom_args = Utils.get_options()["adventure_options"].get("rom_args")
+ options = get_settings().adventure_options
+ auto_start = options.rom_start
+ rom_args = options.rom_args
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
@@ -510,7 +514,7 @@ async def main():
import colorama
- colorama.init()
+ colorama.just_fix_windows_console()
asyncio.run(main())
colorama.deinit()
diff --git a/BaseClasses.py b/BaseClasses.py
index 02d050c66761..4d88fde4f3db 100644
--- a/BaseClasses.py
+++ b/BaseClasses.py
@@ -1,32 +1,40 @@
from __future__ import annotations
-import copy
+import collections
import functools
import logging
import random
import secrets
-import typing # this can go away when Python 3.8 support is dropped
+import warnings
from argparse import Namespace
-from collections import ChainMap, Counter, deque
+from collections import Counter, deque, defaultdict
+from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag
-from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \
- Type, ClassVar
+from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
+ Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload)
+import dataclasses
+
+from typing_extensions import NotRequired, TypedDict
import NetUtils
import Options
import Utils
+if TYPE_CHECKING:
+ from entrance_rando import ERPlacementState
+ from worlds import AutoWorld
+
-class Group(TypedDict, total=False):
+class Group(TypedDict):
name: str
game: str
- world: auto_world
- players: Set[int]
- item_pool: Set[str]
- replacement_items: Dict[int, Optional[str]]
- local_items: Set[str]
- non_local_items: Set[str]
- link_replacement: bool
+ world: "AutoWorld.World"
+ players: AbstractSet[int]
+ item_pool: NotRequired[Set[str]]
+ replacement_items: NotRequired[Dict[int, Optional[str]]]
+ local_items: NotRequired[Set[str]]
+ non_local_items: NotRequired[Set[str]]
+ link_replacement: NotRequired[bool]
class ThreadBarrierProxy:
@@ -43,27 +51,35 @@ def __getattr__(self, name: str) -> Any:
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
+class HasNameAndPlayer(Protocol):
+ name: str
+ player: int
+
+
+@dataclasses.dataclass
+class PlandoItemBlock:
+ player: int
+ from_pool: bool
+ force: bool | Literal["silent"]
+ worlds: set[int] = dataclasses.field(default_factory=set)
+ items: list[str] = dataclasses.field(default_factory=list)
+ locations: list[str] = dataclasses.field(default_factory=list)
+ resolved_locations: list[Location] = dataclasses.field(default_factory=list)
+ count: dict[str, int] = dataclasses.field(default_factory=dict)
+
+
class MultiWorld():
debug_types = False
player_name: Dict[int, str]
- _region_cache: Dict[int, Dict[str, Region]]
- difficulty_requirements: dict
- required_medallions: dict
- dark_room_logic: Dict[int, str]
- restrict_dungeon_item_on_boss: Dict[int, bool]
- plando_texts: List[Dict[str, str]]
- plando_items: List[List[Dict[str, Any]]]
- plando_connections: List
- worlds: Dict[int, auto_world]
+ worlds: Dict[int, "AutoWorld.World"]
groups: Dict[int, Group]
- regions: List[Region]
+ regions: RegionManager
itempool: List[Item]
is_race: bool = False
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]
@@ -78,10 +94,12 @@ class MultiWorld():
start_location_hints: Dict[int, Options.StartLocationHints]
item_links: Dict[int, Options.ItemLinks]
+ plando_item_blocks: Dict[int, List[PlandoItemBlock]]
+
game: Dict[int, str]
random: random.Random
- per_slot_randoms: Dict[int, random.Random]
+ per_slot_randoms: Utils.DeprecateDict[int, random.Random]
"""Deprecated. Please use `self.random` instead."""
class AttributeProxy():
@@ -91,30 +109,56 @@ def __init__(self, rule):
def __getitem__(self, player) -> bool:
return self.rule(player)
+ class RegionManager:
+ region_cache: Dict[int, Dict[str, Region]]
+ entrance_cache: Dict[int, Dict[str, Entrance]]
+ location_cache: Dict[int, Dict[str, Location]]
+
+ def __init__(self, players: int):
+ self.region_cache = {player: {} for player in range(1, players+1)}
+ self.entrance_cache = {player: {} for player in range(1, players+1)}
+ self.location_cache = {player: {} for player in range(1, players+1)}
+
+ def __iadd__(self, other: Iterable[Region]):
+ self.extend(other)
+ return self
+
+ def append(self, region: Region):
+ assert region.name not in self.region_cache[region.player], \
+ f"{region.name} already exists in region cache."
+ self.region_cache[region.player][region.name] = region
+
+ def extend(self, regions: Iterable[Region]):
+ for region in regions:
+ assert region.name not in self.region_cache[region.player], \
+ f"{region.name} already exists in region cache."
+ self.region_cache[region.player][region.name] = region
+
+ def add_group(self, new_id: int):
+ self.region_cache[new_id] = {}
+ self.entrance_cache[new_id] = {}
+ self.location_cache[new_id] = {}
+
+ def __iter__(self) -> Iterator[Region]:
+ for regions in self.region_cache.values():
+ yield from regions.values()
+
+ def __len__(self):
+ return sum(len(regions) for regions in self.region_cache.values())
+
def __init__(self, players: int):
# 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
self.algorithm = 'balanced'
self.groups = {}
- self.regions = []
- self.shops = []
+ self.regions = self.RegionManager(players)
self.itempool = []
self.seed = None
self.seed_name: str = "Unavailable"
self.precollected_items = {player: [] for player in self.player_ids}
- self._cached_entrances = None
- self._cached_locations = None
- self._entrance_cache = {}
- self._location_cache: Dict[Tuple[str, int], Location] = {}
self.required_locations = []
- self.light_world_light_cone = False
- self.dark_world_light_cone = False
- self.rupoor_cost = 10
- self.aga_randomness = True
- self.save_and_quit_from_boss = True
self.custom = False
self.customitemarray = []
self.shuffle_ganon = True
@@ -123,94 +167,41 @@ def __init__(self, players: int):
self.local_early_items = {player: {} for player in self.player_ids}
self.indirect_connections = {}
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
- self.fix_trock_doors = self.AttributeProxy(
- lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
- self.fix_skullwoods_exit = self.AttributeProxy(
- lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
- self.fix_palaceofdarkness_exit = self.AttributeProxy(
- lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
- self.fix_trock_exit = self.AttributeProxy(
- lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
+ self.plando_item_blocks = {}
for player in range(1, players + 1):
- def set_player_attr(attr, val):
+ def set_player_attr(attr: str, val) -> None:
self.__dict__.setdefault(attr, {})[player] = val
-
- set_player_attr('_region_cache', {})
- set_player_attr('shuffle', "vanilla")
- set_player_attr('logic', "noglitches")
- set_player_attr('mode', 'open')
- set_player_attr('difficulty', 'normal')
- set_player_attr('item_functionality', 'normal')
- set_player_attr('timer', False)
- set_player_attr('goal', 'ganon')
- set_player_attr('required_medallions', ['Ether', 'Quake'])
- set_player_attr('swamp_patch_required', False)
- set_player_attr('powder_patch_required', False)
- set_player_attr('ganon_at_pyramid', True)
- set_player_attr('ganonstower_vanilla', True)
- set_player_attr('can_access_trock_eyebridge', None)
- set_player_attr('can_access_trock_front', None)
- set_player_attr('can_access_trock_big_chest', None)
- set_player_attr('can_access_trock_middle', None)
- set_player_attr('fix_fake_world', True)
- set_player_attr('difficulty_requirements', None)
- set_player_attr('boss_shuffle', 'none')
- set_player_attr('enemy_health', 'default')
- set_player_attr('enemy_damage', 'default')
- set_player_attr('beemizer_total_chance', 0)
- set_player_attr('beemizer_trap_chance', 0)
- set_player_attr('escape_assist', [])
- set_player_attr('treasure_hunt_icon', 'Triforce Piece')
- set_player_attr('treasure_hunt_count', 0)
- set_player_attr('clock_mode', False)
- set_player_attr('countdown_start_time', 10)
- set_player_attr('red_clock_time', -2)
- set_player_attr('blue_clock_time', 2)
- set_player_attr('green_clock_time', 4)
- set_player_attr('can_take_damage', True)
- set_player_attr('triforce_pieces_available', 30)
- set_player_attr('triforce_pieces_required', 20)
- set_player_attr('shop_shuffle', 'off')
- set_player_attr('shuffle_prizes', "g")
- set_player_attr('sprite_pool', [])
- set_player_attr('dark_room_logic', "lamp")
- set_player_attr('plando_items', [])
- set_player_attr('plando_texts', {})
- set_player_attr('plando_connections', [])
- set_player_attr('game', "A Link to the Past")
+ set_player_attr('plando_item_blocks', [])
+ set_player_attr('game', "Archipelago")
set_player_attr('completion_condition', lambda state: True)
- self.custom_data = {}
self.worlds = {}
- self.per_slot_randoms = {}
+ self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
+ "world's random object instead (usually self.random)", True)
self.plando_options = PlandoOptions.none
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]:
+ def add_group(self, name: str, game: str, players: AbstractSet[int] = frozenset()) -> Tuple[int, Group]:
"""Create a group with name and return the assigned player ID and group.
If a group of this name already exists, the set of players is extended instead of creating a new one."""
+ from worlds import AutoWorld
+
for group_id, group in self.groups.items():
if group["name"] == name:
group["players"] |= players
return group_id, group
new_id: int = self.players + len(self.groups) + 1
+ self.regions.add_group(new_id)
self.game[new_id] = game
- self.custom_data[new_id] = {}
self.player_types[new_id] = NetUtils.SlotType.group
- self._region_cache[new_id] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[game]
- for option_key, option in world_type.option_definitions.items():
- getattr(self, option_key)[new_id] = option(option.default)
- for option_key, option in Options.common_options.items():
- getattr(self, option_key)[new_id] = option(option.default)
- for option_key, option in Options.per_game_common_options.items():
- getattr(self, option_key)[new_id] = option(option.default)
-
- self.worlds[new_id] = world_type(self, new_id)
- self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
+ self.worlds[new_id] = world_type.create_group(self, new_id, players)
+ self.worlds[new_id].collect_item = AutoWorld.World.collect_item.__get__(self.worlds[new_id])
+ self.worlds[new_id].collect = AutoWorld.World.collect.__get__(self.worlds[new_id])
+ self.worlds[new_id].remove = AutoWorld.World.remove.__get__(self.worlds[new_id])
self.player_name[new_id] = name
new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
@@ -218,39 +209,35 @@ def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tu
return new_id, new_group
- def get_player_groups(self, player) -> Set[int]:
+ def get_player_groups(self, player: int) -> Set[int]:
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
+ assert not self.worlds, "seed needs to be initialized before Worlds"
self.seed = get_seed(seed)
if secure:
self.secure()
else:
self.random.seed(self.seed)
self.seed_name = name if name else str(self.seed)
- 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:
- setattr(self, option_key, getattr(args, option_key, {}))
- for option_key in Options.per_game_common_options:
- setattr(self, option_key, getattr(args, option_key, {}))
+ from worlds import AutoWorld
for player in self.player_ids:
- self.custom_data[player] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
- for option_key in world_type.option_definitions:
- setattr(self, option_key, getattr(args, option_key, {}))
-
self.worlds[player] = world_type(self, player)
- self.worlds[player].random = self.per_slot_randoms[player]
+ options_dataclass: type[Options.PerGameCommonOptions] = world_type.options_dataclass
+ self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
+ for option_key in options_dataclass.type_hints})
def set_item_links(self):
+ from worlds import AutoWorld
+
item_links = {}
replacement_prio = [False, True, None]
for player in self.player_ids:
- for item_link in self.item_links[player].value:
+ for item_link in self.worlds[player].options.item_links.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']}")
@@ -274,9 +261,10 @@ def set_item_links(self):
"local_items": set(item_link.get("local_items", [])),
"non_local_items": set(item_link.get("non_local_items", [])),
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
+ "skip_if_solo": item_link.get("skip_if_solo", False),
}
- for name, item_link in item_links.items():
+ for _name, item_link in item_links.items():
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
pool = set()
local_items = set()
@@ -297,6 +285,8 @@ def set_item_links(self):
for group_name, item_link in item_links.items():
game = item_link["game"]
+ if item_link["skip_if_solo"] and len(item_link["players"]) == 1:
+ continue
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
group["item_pool"] = item_link["item_pool"]
@@ -305,13 +295,87 @@ def set_item_links(self):
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):
- for option_key, option in Options.common_options.items():
- setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
- for option_key, option in Options.per_game_common_options.items():
- setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
- self.state = CollectionState(self)
+ def link_items(self) -> None:
+ """Called to link together items in the itempool related to the registered item link groups."""
+ from worlds import AutoWorld
+
+ for group_id, group in self.groups.items():
+ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
+ Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
+ ]:
+ classifications: Dict[str, int] = collections.defaultdict(int)
+ counters = {player: {name: 0 for name in shared_pool} for player in players}
+ for item in self.itempool:
+ if item.player in counters and item.name in shared_pool:
+ counters[item.player][item.name] += 1
+ classifications[item.name] |= item.classification
+
+ for player in players.copy():
+ if all([counters[player][item] == 0 for item in shared_pool]):
+ players.remove(player)
+ del (counters[player])
+
+ if not players:
+ return None, None
+
+ for item in shared_pool:
+ count = min(counters[player][item] for player in players)
+ if count:
+ for player in players:
+ counters[player][item] = count
+ else:
+ for player in players:
+ del (counters[player][item])
+ return counters, classifications
+
+ common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
+ if not common_item_count:
+ continue
+
+ new_itempool: List[Item] = []
+ for item_name, item_count in next(iter(common_item_count.values())).items():
+ for _ in range(item_count):
+ new_item = group["world"].create_item(item_name)
+ # mangle together all original classification bits
+ new_item.classification |= classifications[item_name]
+ new_itempool.append(new_item)
+
+ region = Region(group["world"].origin_region_name, group_id, self, "ItemLink")
+ self.regions.append(region)
+ locations = region.locations
+ # ensure that progression items are linked first, then non-progression
+ self.itempool.sort(key=lambda item: item.advancement)
+ for item in self.itempool:
+ count = common_item_count.get(item.player, {}).get(item.name, 0)
+ if count:
+ loc = Location(group_id, f"Item Link: {item.name} -> {self.player_name[item.player]} {count}",
+ None, region)
+ loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
+ state.has(item_name, group_id_, count_)
+
+ locations.append(loc)
+ loc.place_locked_item(item)
+ common_item_count[item.player][item.name] -= 1
+ else:
+ new_itempool.append(item)
+
+ itemcount = len(self.itempool)
+ self.itempool = new_itempool
+
+ while itemcount > len(self.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(self, "create_item", item_player,
+ group["replacement_items"][player]))
+ else:
+ items_to_add.append(AutoWorld.call_single(self, "create_filler", item_player))
+ self.random.shuffle(items_to_add)
+ self.itempool.extend(items_to_add[:itemcount - len(self.itempool)])
def secure(self):
self.random = ThreadBarrierProxy(secrets.SystemRandom())
@@ -321,16 +385,20 @@ def secure(self):
def player_ids(self) -> Tuple[int, ...]:
return tuple(range(1, self.players + 1))
- @functools.lru_cache()
+ @Utils.cache_self1
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()
+ @Utils.cache_self1
+ def get_game_groups(self, game_name: str) -> Tuple[int, ...]:
+ return tuple(group_id for group_id in self.groups if self.game[group_id] == game_name)
+
+ @Utils.cache_self1
def get_game_worlds(self, game_name: str):
return tuple(world for player, world in self.worlds.items() if
player not in self.groups and self.game[player] == game_name)
- def get_name_string_for_object(self, obj) -> str:
+ def get_name_string_for_object(self, obj: HasNameAndPlayer) -> str:
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
def get_player_name(self, player: int) -> str:
@@ -343,74 +411,61 @@ 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}_{self.get_file_safe_player_name(player).replace(' ', '_')}"
- def initialize_regions(self, regions=None):
- for region in regions if regions else self.regions:
- region.multiworld = self
- self._region_cache[region.player][region.name] = region
-
@functools.cached_property
def world_name_lookup(self):
return {self.player_name[player_id]: player_id for player_id in self.player_ids}
- def _recache(self):
- """Rebuild world cache"""
- self._cached_locations = None
- for region in self.regions:
- player = region.player
- self._region_cache[player][region.name] = region
- for exit in region.exits:
- self._entrance_cache[exit.name, player] = exit
-
- for r_location in region.locations:
- self._location_cache[r_location.name, player] = r_location
-
- def get_regions(self, player=None):
- return self.regions if player is None else self._region_cache[player].values()
-
- def get_region(self, regionname: str, player: int) -> Region:
- try:
- return self._region_cache[player][regionname]
- except KeyError:
- self._recache()
- return self._region_cache[player][regionname]
+ def get_regions(self, player: Optional[int] = None) -> Collection[Region]:
+ return self.regions if player is None else self.regions.region_cache[player].values()
- def get_entrance(self, entrance: str, player: int) -> Entrance:
- try:
- return self._entrance_cache[entrance, player]
- except KeyError:
- self._recache()
- return self._entrance_cache[entrance, player]
+ def get_region(self, region_name: str, player: int) -> Region:
+ return self.regions.region_cache[player][region_name]
- def get_location(self, location: str, player: int) -> Location:
- try:
- return self._location_cache[location, player]
- except KeyError:
- self._recache()
- return self._location_cache[location, player]
+ def get_entrance(self, entrance_name: str, player: int) -> Entrance:
+ return self.regions.entrance_cache[player][entrance_name]
- def get_all_state(self, use_cache: bool) -> CollectionState:
- cached = getattr(self, "_all_state", None)
- if use_cache and cached:
- return cached.copy()
+ def get_location(self, location_name: str, player: int) -> Location:
+ return self.regions.location_cache[player][location_name]
- ret = CollectionState(self)
+ def get_all_state(self, use_cache: bool | None = None, allow_partial_entrances: bool = False,
+ collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
+ """
+ Creates a new CollectionState, and collects all precollected items, all items in the multiworld itempool, those
+ specified in each worlds' `get_pre_fill_items()`, and then sweeps the multiworld collecting any other items
+ it is able to reach, building as complete of a completed game state as possible.
+
+ :param use_cache: Deprecated and unused.
+ :param allow_partial_entrances: Whether the CollectionState should allow for disconnected entrances while
+ sweeping, such as before entrance randomization is complete.
+ :param collect_pre_fill_items: Whether the items in each worlds' `get_pre_fill_items()` should be added to this
+ state.
+ :param perform_sweep: Whether this state should perform a sweep for reachable locations, collecting any placed
+ items it can.
+
+ :return: The completed CollectionState.
+ """
+ if __debug__ and use_cache is not None:
+ # TODO swap to Utils.deprecate when we want this to crash on source and warn on frozen
+ warnings.warn("multiworld.get_all_state no longer caches all_state and this argument will be removed.",
+ DeprecationWarning)
+ ret = CollectionState(self, allow_partial_entrances)
for item in self.itempool:
self.worlds[item.player].collect(ret, item)
- for player in self.player_ids:
- subworld = self.worlds[player]
- for item in subworld.get_pre_fill_items():
- subworld.collect(ret, item)
- ret.sweep_for_events()
+ if collect_pre_fill_items:
+ for player in self.player_ids:
+ subworld = self.worlds[player]
+ for item in subworld.get_pre_fill_items():
+ subworld.collect(ret, item)
+ if perform_sweep:
+ ret.sweep_for_advancements()
- if use_cache:
- self._all_state = ret
return ret
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, resolve_group_locations: bool = False) -> List[Location]:
+ def find_item_locations(self, item: 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
@@ -419,7 +474,7 @@ def find_item_locations(self, item, player: int, resolve_group_locations: bool =
return [location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player]
- def find_item(self, item, player: int) -> Location:
+ def find_item(self, item: str, player: int) -> Location:
return next(location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player)
@@ -443,32 +498,26 @@ def push_item(self, location: Location, item: Item, collect: bool = True):
location.item = item
item.location = location
if collect:
- self.state.collect(item, location.event, location)
+ self.state.collect(item, location.advancement, location)
logging.debug('Placed %s at %s', item, location)
- def get_entrances(self) -> List[Entrance]:
- if self._cached_entrances is None:
- self._cached_entrances = [entrance for region in self.regions for entrance in region.entrances]
- return self._cached_entrances
-
- def clear_entrance_cache(self):
- self._cached_entrances = None
+ def get_entrances(self, player: Optional[int] = None) -> Iterable[Entrance]:
+ if player is not None:
+ return self.regions.entrance_cache[player].values()
+ return Utils.RepeatableChain(tuple(self.regions.entrance_cache[player].values()
+ for player in self.regions.entrance_cache))
def register_indirect_condition(self, region: Region, entrance: Entrance):
"""Report that access to this Region can result in unlocking this Entrance,
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, 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]
+ def get_locations(self, player: Optional[int] = None) -> Iterable[Location]:
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
+ return self.regions.location_cache[player].values()
+ return Utils.RepeatableChain(tuple(self.regions.location_cache[player].values()
+ for player in self.regions.location_cache))
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]:
return [location for location in self.get_locations(player) if location.item is None]
@@ -490,16 +539,17 @@ def get_unfilled_locations_for_players(self, location_names: List[str], players:
valid_locations = [location.name for location in self.get_unfilled_locations(player)]
else:
valid_locations = location_names
+ relevant_cache = self.regions.location_cache[player]
for location_name in valid_locations:
- location = self._location_cache.get((location_name, player), None)
- if location is not None and location.item is None:
+ location = relevant_cache.get(location_name, None)
+ if location and location.item is None:
yield location
def unlocks_new_location(self, item: Item) -> bool:
temp_state = self.state.copy()
temp_state.collect(item, True)
- for location in self.get_unfilled_locations():
+ for location in self.get_unfilled_locations(item.player):
if temp_state.can_reach(location) and not self.state.can_reach(location):
return True
@@ -511,49 +561,86 @@ def has_beaten_game(self, state: CollectionState, player: Optional[int] = None)
else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
- def can_beat_game(self, starting_state: Optional[CollectionState] = None):
+ def can_beat_game(self,
+ starting_state: Optional[CollectionState] = None,
+ locations: Optional[Iterable[Location]] = None) -> bool:
if starting_state:
if self.has_beaten_game(starting_state):
return True
state = starting_state.copy()
else:
- if self.has_beaten_game(self.state):
- return True
state = CollectionState(self)
- prog_locations = {location for location in self.get_locations() if location.item
- and location.item.advancement and location not in state.locations_checked}
+ if self.has_beaten_game(state):
+ return True
- while prog_locations:
- sphere = set()
- # build up spheres of collection radius.
- # Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
- for location in prog_locations:
+ for _ in state.sweep_for_advancements(locations,
+ yield_each_sweep=True,
+ checked_locations=state.locations_checked):
+ if self.has_beaten_game(state):
+ return True
+
+ return False
+
+ def get_spheres(self) -> Iterator[Set[Location]]:
+ """
+ yields a set of locations for each logical sphere
+
+ If there are unreachable locations, the last sphere of reachable
+ locations is followed by an empty set, and then a set of all of the
+ unreachable locations.
+ """
+ state = CollectionState(self)
+ locations = set(self.get_filled_locations())
+
+ while locations:
+ sphere: Set[Location] = set()
+
+ for location in locations:
if location.can_reach(state):
sphere.add(location)
-
+ yield sphere
if not sphere:
- # ran out of places and did not finish yet, quit
- return False
+ if locations:
+ yield locations # unreachable locations
+ break
for location in sphere:
state.collect(location.item, True, location)
- prog_locations -= sphere
-
- if self.has_beaten_game(state):
- return True
+ locations -= sphere
- return False
+ def get_sendable_spheres(self) -> Iterator[Set[Location]]:
+ """
+ yields a set of multiserver sendable locations (location.item.code: int) for each logical sphere
- def get_spheres(self):
+ If there are unreachable locations, the last sphere of reachable locations is followed by an empty set,
+ and then a set of all of the unreachable locations.
+ """
state = CollectionState(self)
- locations = set(self.get_filled_locations())
+ locations: Set[Location] = set()
+ events: Set[Location] = set()
+ for location in self.get_filled_locations():
+ if type(location.item.code) is int and type(location.address) is int:
+ locations.add(location)
+ else:
+ events.add(location)
while locations:
- sphere = set()
+ sphere: Set[Location] = set()
+
+ # cull events out
+ done_events: Set[Union[Location, None]] = {None}
+ while done_events:
+ done_events = set()
+ for event in events:
+ if event.can_reach(state):
+ state.collect(event.item, True, event)
+ done_events.add(event)
+ events -= done_events
for location in locations:
if location.can_reach(state):
sphere.add(location)
+
yield sphere
if not sphere:
if locations:
@@ -571,26 +658,21 @@ def fulfills_accessibility(self, state: Optional[CollectionState] = None):
players: Dict[str, Set[int]] = {
"minimal": set(),
"items": set(),
- "locations": set()
+ "full": set()
}
- for player, access in self.accessibility.items():
- players[access.current_key].add(player)
+ for player, world in self.worlds.items():
+ players[world.options.accessibility.current_key].add(player)
beatable_fulfilled = False
- def location_condition(location: Location):
+ def location_condition(location: Location) -> bool:
"""Determine if this location has to be accessible, location is already filtered by location_relevant"""
- if location.player in players["minimal"]:
- return False
- return True
+ return location.player in players["full"] or \
+ (location.item and location.item.player not in players["minimal"])
- def location_relevant(location: Location):
+ def location_relevant(location: Location) -> bool:
"""Determine if this location is relevant to sweep."""
- if location.progress_type != LocationProgressType.EXCLUDED \
- and (location.player in players["locations"] or location.event
- or (location.item and location.item.advancement)):
- return True
- return False
+ return location.player in players["full"] or location.advancement
def all_done() -> bool:
"""Check if all access rules are fulfilled"""
@@ -609,6 +691,12 @@ def all_done() -> bool:
sphere.append(locations.pop(n))
if not sphere:
+ if __debug__:
+ from Fill import FillError
+ raise FillError(
+ f"Could not access required locations for accessibility check. Missing: {locations}",
+ multiworld=self,
+ )
# ran out of places and did not finish yet, quit
logging.warning(f"Could not access required locations for accessibility check."
f" Missing: {locations}")
@@ -631,26 +719,29 @@ def all_done() -> bool:
class CollectionState():
- prog_items: typing.Counter[Tuple[str, int]]
+ prog_items: Dict[int, Counter[str]]
multiworld: MultiWorld
reachable_regions: Dict[int, Set[Region]]
blocked_connections: Dict[int, Set[Entrance]]
- events: Set[Location]
+ advancements: Set[Location]
path: Dict[Union[Region, Entrance], PathValue]
locations_checked: Set[Location]
stale: Dict[int, bool]
+ allow_partial_entrances: bool
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
- def __init__(self, parent: MultiWorld):
- self.prog_items = Counter()
+ def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
+ assert parent.worlds, "CollectionState created without worlds initialized in parent"
+ self.prog_items = {player: Counter() for player in parent.get_all_ids()}
self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
- self.events = set()
+ self.advancements = set()
self.path = {}
self.locations_checked = set()
self.stale = {player: True for player in parent.get_all_ids()}
+ self.allow_partial_entrances = allow_partial_entrances
for function in self.additional_init_functions:
function(self, parent)
for items in parent.precollected_items.values():
@@ -659,46 +750,82 @@ def __init__(self, parent: MultiWorld):
def update_reachable_regions(self, player: int):
self.stale[player] = False
- rrp = self.reachable_regions[player]
- bc = self.blocked_connections[player]
+ world: AutoWorld.World = self.multiworld.worlds[player]
+ reachable_regions = self.reachable_regions[player]
queue = deque(self.blocked_connections[player])
- start = self.multiworld.get_region('Menu', player)
+ start: Region = world.get_region(world.origin_region_name)
# init on first call - this can't be done on construction since the regions don't exist yet
- if start not in rrp:
- rrp.add(start)
- bc.update(start.exits)
+ if start not in reachable_regions:
+ reachable_regions.add(start)
+ self.blocked_connections[player].update(start.exits)
queue.extend(start.exits)
+ if world.explicit_indirect_conditions:
+ self._update_reachable_regions_explicit_indirect_conditions(player, queue)
+ else:
+ self._update_reachable_regions_auto_indirect_conditions(player, queue)
+
+ def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque):
+ reachable_regions = self.reachable_regions[player]
+ blocked_connections = self.blocked_connections[player]
# run BFS on all connections, and keep track of those blocked by missing items
while queue:
connection = queue.popleft()
new_region = connection.connected_region
- if new_region in rrp:
- bc.remove(connection)
+ if new_region in reachable_regions:
+ blocked_connections.remove(connection)
elif connection.can_reach(self):
- assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
- rrp.add(new_region)
- bc.remove(connection)
- bc.update(new_region.exits)
+ if self.allow_partial_entrances and not new_region:
+ continue
+ assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
+ reachable_regions.add(new_region)
+ blocked_connections.remove(connection)
+ blocked_connections.update(new_region.exits)
queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None))
# Retry connections if the new region can unblock them
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
- if new_entrance in bc and new_entrance not in queue:
+ if new_entrance in blocked_connections and new_entrance not in queue:
queue.append(new_entrance)
+ def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque):
+ reachable_regions = self.reachable_regions[player]
+ blocked_connections = self.blocked_connections[player]
+ new_connection: bool = True
+ # run BFS on all connections, and keep track of those blocked by missing items
+ while new_connection:
+ new_connection = False
+ while queue:
+ connection = queue.popleft()
+ new_region = connection.connected_region
+ if new_region in reachable_regions:
+ blocked_connections.remove(connection)
+ elif connection.can_reach(self):
+ if self.allow_partial_entrances and not new_region:
+ continue
+ assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
+ reachable_regions.add(new_region)
+ blocked_connections.remove(connection)
+ blocked_connections.update(new_region.exits)
+ queue.extend(new_region.exits)
+ self.path[new_region] = (new_region.name, self.path.get(connection, None))
+ new_connection = True
+ # sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
+ queue.extend(blocked_connections)
+
def copy(self) -> CollectionState:
ret = CollectionState(self.multiworld)
- ret.prog_items = self.prog_items.copy()
- ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
- self.reachable_regions}
- ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
- self.blocked_connections}
- ret.events = copy.copy(self.events)
- ret.path = copy.copy(self.path)
- ret.locations_checked = copy.copy(self.locations_checked)
+ ret.prog_items = {player: counter.copy() for player, counter in self.prog_items.items()}
+ ret.reachable_regions = {player: region_set.copy() for player, region_set in
+ self.reachable_regions.items()}
+ ret.blocked_connections = {player: entrance_set.copy() for player, entrance_set in
+ self.blocked_connections.items()}
+ ret.advancements = self.advancements.copy()
+ ret.path = self.path.copy()
+ ret.locations_checked = self.locations_checked.copy()
+ ret.allow_partial_entrances = self.allow_partial_entrances
for function in self.additional_copy_functions:
ret = function(self, ret)
return ret
@@ -711,77 +838,301 @@ def can_reach(self,
assert isinstance(player, int), "can_reach: player is required if spot is str"
# try to resolve a name
if resolution_hint == 'Location':
- spot = self.multiworld.get_location(spot, player)
+ return self.can_reach_location(spot, player)
elif resolution_hint == 'Entrance':
- spot = self.multiworld.get_entrance(spot, player)
+ return self.can_reach_entrance(spot, player)
else:
# default to Region
- spot = self.multiworld.get_region(spot, player)
+ return self.can_reach_region(spot, player)
return spot.can_reach(self)
- def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
+ def can_reach_location(self, spot: str, player: int) -> bool:
+ return self.multiworld.get_location(spot, player).can_reach(self)
+
+ def can_reach_entrance(self, spot: str, player: int) -> bool:
+ return self.multiworld.get_entrance(spot, player).can_reach(self)
+
+ def can_reach_region(self, spot: str, player: int) -> bool:
+ return self.multiworld.get_region(spot, player).can_reach(self)
+
+ def sweep_for_events(self, locations: Optional[Iterable[Location]] = None) -> None:
+ Utils.deprecate("sweep_for_events has been renamed to sweep_for_advancements. The functionality is the same. "
+ "Please switch over to sweep_for_advancements.")
+ return self.sweep_for_advancements(locations)
+
+ def _sweep_for_advancements_impl(self, advancements_per_player: List[Tuple[int, List[Location]]],
+ yield_each_sweep: bool) -> Iterator[None]:
+ """
+ The implementation for sweep_for_advancements is separated here because it returns a generator due to the use
+ of a yield statement.
+ """
+ all_players = {player for player, _ in advancements_per_player}
+ players_to_check = all_players
+ # As an optimization, it is assumed that each player's world only logically depends on itself. However, worlds
+ # are allowed to logically depend on other worlds, so once there are no more players that should be checked
+ # under this assumption, an extra sweep iteration is performed that checks every player, to confirm that the
+ # sweep is finished.
+ checking_if_finished = False
+ while players_to_check:
+ next_advancements_per_player: List[Tuple[int, List[Location]]] = []
+ next_players_to_check = set()
+
+ for player, locations in advancements_per_player:
+ if player not in players_to_check:
+ next_advancements_per_player.append((player, locations))
+ continue
+
+ # Accessibility of each location is checked first because a player's region accessibility cache becomes
+ # stale whenever one of their own items is collected into the state.
+ reachable_locations: List[Location] = []
+ unreachable_locations: List[Location] = []
+ for location in locations:
+ if location.can_reach(self):
+ # Locations containing items that do not belong to `player` could be collected immediately
+ # because they won't stale `player`'s region accessibility cache, but, for simplicity, all the
+ # items at reachable locations are collected in a single loop.
+ reachable_locations.append(location)
+ else:
+ unreachable_locations.append(location)
+ if unreachable_locations:
+ next_advancements_per_player.append((player, unreachable_locations))
+
+ # A previous player's locations processed in the current `while players_to_check` iteration could have
+ # collected items belonging to `player`, but now that all of `player`'s reachable locations have been
+ # found, it can be assumed that `player` will not gain any more reachable locations until another one of
+ # their items is collected.
+ # It would be clearer to not add players to `next_players_to_check` in the first place if they have yet
+ # to be processed in the current `while players_to_check` iteration, but checking if a player should be
+ # added to `next_players_to_check` would need to be run once for every item that is collected, so it is
+ # more performant to instead discard `player` from `next_players_to_check` once their locations have
+ # been processed.
+ next_players_to_check.discard(player)
+
+ # Collect the items from the reachable locations.
+ for advancement in reachable_locations:
+ self.advancements.add(advancement)
+ item = advancement.item
+ assert isinstance(item, Item), "tried to collect advancement Location with no Item"
+ if self.collect(item, True, advancement):
+ # The player the item belongs to may be able to reach additional locations in the next sweep
+ # iteration.
+ next_players_to_check.add(item.player)
+
+ if not next_players_to_check:
+ if not checking_if_finished:
+ # It is assumed that each player's world only logically depends on itself, which may not be the
+ # case, so confirm that the sweep is finished by doing an extra iteration that checks every player.
+ checking_if_finished = True
+ next_players_to_check = all_players
+ else:
+ checking_if_finished = False
+
+ players_to_check = next_players_to_check
+ advancements_per_player = next_advancements_per_player
+
+ if yield_each_sweep:
+ yield
+
+ @overload
+ def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, *,
+ yield_each_sweep: Literal[True],
+ checked_locations: Optional[Set[Location]] = None) -> Iterator[None]: ...
+
+ @overload
+ def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None,
+ yield_each_sweep: Literal[False] = False,
+ checked_locations: Optional[Set[Location]] = None) -> None: ...
+
+ def sweep_for_advancements(self, locations: Optional[Iterable[Location]] = None, yield_each_sweep: bool = False,
+ checked_locations: Optional[Set[Location]] = None) -> Optional[Iterator[None]]:
+ """
+ Sweep through the locations that contain uncollected advancement items, collecting the items into the state
+ until there are no more reachable locations that contain uncollected advancement items.
+
+ :param locations: The locations to sweep through, defaulting to all locations in the multiworld.
+ :param yield_each_sweep: When True, return a generator that yields at the end of each sweep iteration.
+ :param checked_locations: Optional override of locations to filter out from the locations argument, defaults to
+ self.advancements when None.
+ """
+ if checked_locations is None:
+ checked_locations = self.advancements
+
+ # Since the sweep loop usually performs many iterations, the locations are filtered in advance.
+ # A list of tuples is used, instead of a dictionary, because it is faster to iterate.
+ advancements_per_player: List[Tuple[int, List[Location]]]
if locations is None:
- locations = self.multiworld.get_filled_locations()
- reachable_events = True
- # since the loop has a good chance to run more than once, only filter the events once
- locations = {location for location in locations if location.event and location not in self.events and
- not key_only or getattr(location.item, "locked_dungeon_item", False)}
- while reachable_events:
- reachable_events = {location for location in locations if location.can_reach(self)}
- locations -= reachable_events
- for event in reachable_events:
- self.events.add(event)
- assert isinstance(event.item, Item), "tried to collect Event with no Item"
- self.collect(event.item, True, event)
+ # `location.advancement` can only be True for filled locations, so unfilled locations are filtered out.
+ advancements_per_player = []
+ for player, locations_dict in self.multiworld.regions.location_cache.items():
+ filtered_locations = [location for location in locations_dict.values()
+ if location.advancement and location not in checked_locations]
+ if filtered_locations:
+ advancements_per_player.append((player, filtered_locations))
+ else:
+ # Filter and separate the locations into a list for each player.
+ advancements_per_player_dict: Dict[int, List[Location]] = defaultdict(list)
+ for location in locations:
+ if location.advancement and location not in checked_locations:
+ advancements_per_player_dict[location.player].append(location)
+ # Convert to a list of tuples.
+ advancements_per_player = list(advancements_per_player_dict.items())
+ del advancements_per_player_dict
+
+ if yield_each_sweep:
+ # Return a generator that will yield at the end of each sweep iteration.
+ return self._sweep_for_advancements_impl(advancements_per_player, True)
+ else:
+ # Create the generator, but tell it not to yield anything, so it will run to completion in zero iterations
+ # once started, then start and exhaust the generator by attempting to iterate it.
+ for _ in self._sweep_for_advancements_impl(advancements_per_player, False):
+ assert False, "Generator yielded when it should have run to completion without yielding"
+ return None
+ # item name related
def has(self, item: str, player: int, count: int = 1) -> bool:
- return self.prog_items[item, player] >= count
+ return self.prog_items[player][item] >= count
- def has_all(self, items: Set[str], player: int) -> bool:
+ # for loops are specifically used in all/any/count methods, instead of all()/any()/sum(), to avoid the overhead of
+ # creating and iterating generator instances. In `return all(player_prog_items[item] for item in items)`, the
+ # argument to all() would be a new generator instance, for example.
+ def has_all(self, items: Iterable[str], player: int) -> bool:
"""Returns True if each item name of items is in state at least once."""
- return all(self.prog_items[item, player] for item in items)
+ player_prog_items = self.prog_items[player]
+ for item in items:
+ if not player_prog_items[item]:
+ return False
+ return True
- def has_any(self, items: Set[str], player: int) -> bool:
+ def has_any(self, items: Iterable[str], player: int) -> bool:
"""Returns True if at least one item name of items is in state at least once."""
- return any(self.prog_items[item, player] for item in items)
+ player_prog_items = self.prog_items[player]
+ for item in items:
+ if player_prog_items[item]:
+ return True
+ return False
+
+ def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
+ """Returns True if each item name is in the state at least as many times as specified."""
+ player_prog_items = self.prog_items[player]
+ for item, count in item_counts.items():
+ if player_prog_items[item] < count:
+ return False
+ return True
+
+ def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
+ """Returns True if at least one item name is in the state at least as many times as specified."""
+ player_prog_items = self.prog_items[player]
+ for item, count in item_counts.items():
+ if player_prog_items[item] >= count:
+ return True
+ return False
def count(self, item: str, player: int) -> int:
- return self.prog_items[item, player]
+ return self.prog_items[player][item]
+
+ def has_from_list(self, items: Iterable[str], player: int, count: int) -> bool:
+ """Returns True if the state contains at least `count` items matching any of the item names from a list."""
+ found: int = 0
+ player_prog_items = self.prog_items[player]
+ for item_name in items:
+ found += player_prog_items[item_name]
+ if found >= count:
+ return True
+ return False
+
+ def has_from_list_unique(self, items: Iterable[str], player: int, count: int) -> bool:
+ """Returns True if the state contains at least `count` items matching any of the item names from a list.
+ Ignores duplicates of the same item."""
+ found: int = 0
+ player_prog_items = self.prog_items[player]
+ for item_name in items:
+ found += player_prog_items[item_name] > 0
+ if found >= count:
+ return True
+ return False
+ def count_from_list(self, items: Iterable[str], player: int) -> int:
+ """Returns the cumulative count of items from a list present in state."""
+ player_prog_items = self.prog_items[player]
+ total = 0
+ for item_name in items:
+ total += player_prog_items[item_name]
+ return total
+
+ def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
+ """Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
+ player_prog_items = self.prog_items[player]
+ total = 0
+ for item_name in items:
+ if player_prog_items[item_name] > 0:
+ total += 1
+ return total
+
+ # item name group related
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
+ """Returns True if the state contains at least `count` items present in a specified item group."""
found: int = 0
+ player_prog_items = self.prog_items[player]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
- found += self.prog_items[item_name, player]
+ found += player_prog_items[item_name]
if found >= count:
return True
return False
- def count_group(self, item_name_group: str, player: int) -> int:
+ def has_group_unique(self, item_name_group: str, player: int, count: int = 1) -> bool:
+ """Returns True if the state contains at least `count` items present in a specified item group.
+ Ignores duplicates of the same item.
+ """
found: int = 0
+ player_prog_items = self.prog_items[player]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
- found += self.prog_items[item_name, player]
- return found
-
- def item_count(self, item: str, player: int) -> int:
- return self.prog_items[item, player]
+ found += player_prog_items[item_name] > 0
+ if found >= count:
+ return True
+ return False
- def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
+ def count_group(self, item_name_group: str, player: int) -> int:
+ """Returns the cumulative count of items from an item group present in state."""
+ player_prog_items = self.prog_items[player]
+ return sum(
+ player_prog_items[item_name]
+ for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]
+ )
+
+ def count_group_unique(self, item_name_group: str, player: int) -> int:
+ """Returns the cumulative count of items from an item group present in state.
+ Ignores duplicates of the same item."""
+ player_prog_items = self.prog_items[player]
+ return sum(
+ player_prog_items[item_name] > 0
+ for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]
+ )
+
+ # Item related
+ def collect(self, item: Item, prevent_sweep: bool = False, location: Optional[Location] = None) -> bool:
if location:
self.locations_checked.add(location)
changed = self.multiworld.worlds[item.player].collect(self, item)
- if not changed and event:
- self.prog_items[item.name, item.player] += 1
- changed = True
-
self.stale[item.player] = True
- if changed and not event:
- self.sweep_for_events()
+ if changed and not prevent_sweep:
+ self.sweep_for_advancements()
return changed
+ def add_item(self, item: str, player: int, count: int = 1) -> None:
+ """
+ Adds the item to state.
+
+ :param item: The item to be added.
+ :param player: The player the item is for.
+ :param count: How many of the item to add.
+ """
+ assert count > 0
+ self.prog_items[player][item] += count
+
def remove(self, item: Item):
changed = self.multiworld.worlds[item.player].remove(self, item)
if changed:
@@ -790,6 +1141,38 @@ def remove(self, item: Item):
self.blocked_connections[item.player] = set()
self.stale[item.player] = True
+ def remove_item(self, item: str, player: int, count: int = 1) -> None:
+ """
+ Removes the item from state.
+
+ :param item: The item to be removed.
+ :param player: The player the item is for.
+ :param count: How many of the item to remove.
+ """
+ assert count > 0
+ self.prog_items[player][item] -= count
+ if self.prog_items[player][item] < 1:
+ del (self.prog_items[player][item])
+
+ def set_item(self, item: str, player: int, count: int) -> None:
+ """
+ Sets the item in state equal to the provided count.
+
+ :param item: The item to modify.
+ :param player: The player the item is for.
+ :param count: How many of the item to now have.
+ """
+ assert count >= 0
+ if count == 0:
+ del (self.prog_items[player][item])
+ else:
+ self.prog_items[player][item] = count
+
+
+class EntranceType(IntEnum):
+ ONE_WAY = 1
+ TWO_WAY = 2
+
class Entrance:
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
@@ -798,35 +1181,59 @@ class Entrance:
name: str
parent_region: Optional[Region]
connected_region: Optional[Region] = None
- # LttP specific, TODO: should make a LttPEntrance
- addresses = None
- target = None
+ randomization_group: int
+ randomization_type: EntranceType
- def __init__(self, player: int, name: str = '', parent: Region = None):
+ def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
+ randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
self.name = name
self.parent_region = parent
self.player = player
+ self.randomization_group = randomization_group
+ self.randomization_type = randomization_type
def can_reach(self, state: CollectionState) -> bool:
+ assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
if self.parent_region.can_reach(state) and self.access_rule(state):
- if not self.hide_path and not self in state.path:
+ if not self.hide_path and self not in state.path:
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
return True
return False
- def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
+ def connect(self, region: Region) -> None:
self.connected_region = region
- self.target = target
- self.addresses = addresses
region.entrances.append(self)
- def __repr__(self):
- return self.__str__()
+ def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
+ """
+ Determines whether this is a valid source transition, that is, whether the entrance
+ randomizer is allowed to pair it to place any other regions. By default, this is the
+ same as a reachability check, but can be modified by Entrance implementations to add
+ other restrictions based on the placement state.
+
+ :param er_state: The current (partial) state of the ongoing entrance randomization
+ """
+ return self.can_reach(er_state.collection_state)
+
+ def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool:
+ """
+ Determines whether a given Entrance is a valid target transition, that is, whether
+ the entrance randomizer is allowed to pair this Entrance to that Entrance. By default,
+ only allows connection between entrances of the same type (one ways only go to one ways,
+ two ways always go to two ways) and prevents connecting an exit to itself in coupled mode.
+
+ :param other: The proposed Entrance to connect to
+ :param dead_end: Whether the other entrance considered a dead end by Entrance randomization
+ :param er_state: The current (partial) state of the ongoing entrance randomization
+ """
+ # the implementation of coupled causes issues for self-loops since the reverse entrance will be the
+ # same as the forward entrance. In uncoupled they are ok.
+ return self.randomization_type == other.randomization_type and (not er_state.coupled or self.name != other.name)
- def __str__(self):
- world = self.parent_region.multiworld if self.parent_region else None
- return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
+ def __repr__(self):
+ multiworld = self.parent_region.multiworld if self.parent_region else None
+ return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
class Region:
@@ -837,30 +1244,97 @@ class Region:
entrances: List[Entrance]
exits: List[Entrance]
locations: List[Location]
- entrance_type: ClassVar[Type[Entrance]] = Entrance
+ entrance_type: ClassVar[type[Entrance]] = Entrance
+
+ class Register(MutableSequence):
+ region_manager: MultiWorld.RegionManager
+
+ def __init__(self, region_manager: MultiWorld.RegionManager):
+ self._list = []
+ self.region_manager = region_manager
+
+ def __getitem__(self, index: int) -> Location:
+ return self._list[index]
+
+ def __setitem__(self, index: int, value: Location) -> None:
+ raise NotImplementedError()
+
+ def __len__(self) -> int:
+ return len(self._list)
+
+ def __iter__(self):
+ return iter(self._list)
+
+ # This seems to not be needed, but that's a bit suspicious.
+ # def __del__(self):
+ # self.clear()
+
+ def copy(self):
+ return self._list.copy()
+
+ class LocationRegister(Register):
+ def __delitem__(self, index: int) -> None:
+ location: Location = self._list[index]
+ del self._list[index]
+ del(self.region_manager.location_cache[location.player][location.name])
+
+ def insert(self, index: int, value: Location) -> None:
+ assert value.name not in self.region_manager.location_cache[value.player], \
+ f"{value.name} already exists in the location cache."
+ self._list.insert(index, value)
+ self.region_manager.location_cache[value.player][value.name] = value
+
+ class EntranceRegister(Register):
+ def __delitem__(self, index: int) -> None:
+ entrance: Entrance = self._list[index]
+ del self._list[index]
+ del(self.region_manager.entrance_cache[entrance.player][entrance.name])
+
+ def insert(self, index: int, value: Entrance) -> None:
+ assert value.name not in self.region_manager.entrance_cache[value.player], \
+ f"{value.name} already exists in the entrance cache."
+ self._list.insert(index, value)
+ self.region_manager.entrance_cache[value.player][value.name] = value
+
+ _locations: LocationRegister[Location]
+ _exits: EntranceRegister[Entrance]
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
self.name = name
self.entrances = []
- self.exits = []
- self.locations = []
+ self._exits = self.EntranceRegister(multiworld.regions)
+ self._locations = self.LocationRegister(multiworld.regions)
self.multiworld = multiworld
self._hint_text = hint
self.player = player
+ def get_locations(self):
+ return self._locations
+
+ def set_locations(self, new):
+ if new is self._locations:
+ return
+ self._locations.clear()
+ self._locations.extend(new)
+
+ locations = property(get_locations, set_locations)
+
+ def get_exits(self):
+ return self._exits
+
+ def set_exits(self, new):
+ if new is self._exits:
+ return
+ self._exits.clear()
+ self._exits.extend(new)
+
+ exits = property(get_exits, set_exits)
+
def can_reach(self, state: CollectionState) -> bool:
if state.stale[self.player]:
state.update_reachable_regions(self.player)
return self in state.reachable_regions[self.player]
- def can_reach_private(self, state: CollectionState) -> bool:
- for entrance in self.entrances:
- if entrance.can_reach(state):
- if not self in state.path:
- state.path[self] = (self.name, state.path.get(entrance, None))
- return True
- return False
-
@property
def hint_text(self) -> str:
return self._hint_text if self._hint_text else self.name
@@ -872,24 +1346,65 @@ def get_connecting_entrance(self, is_main_entrance: Callable[[Entrance], bool])
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
- def add_locations(self, locations: Dict[str, Optional[int]],
- location_type: Optional[Type[Location]] = None) -> None:
+ def add_locations(self, locations: Mapping[str, int | None], location_type: type[Location] | None = None) -> None:
"""
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address.
-
+
:param locations: dictionary of locations to be created and added to this Region `{name: ID}`
:param location_type: Location class to be used to create the locations with"""
if location_type is None:
location_type = Location
for location, address in locations.items():
self.locations.append(location_type(self.player, location, address, self))
-
+
+ def add_event(
+ self,
+ location_name: str,
+ item_name: str | None = None,
+ rule: Callable[[CollectionState], bool] | None = None,
+ location_type: type[Location] | None = None,
+ item_type: type[Item] | None = None,
+ show_in_spoiler: bool = True,
+ ) -> Item:
+ """
+ Adds an event location/item pair to the region.
+
+ :param location_name: Name for the event location.
+ :param item_name: Name for the event item. If not provided, defaults to location_name.
+ :param rule: Callable to determine access for this event location within its region.
+ :param location_type: Location class to create the event location with. Defaults to BaseClasses.Location.
+ :param item_type: Item class to create the event item with. Defaults to BaseClasses.Item.
+ :param show_in_spoiler: Will be passed along to the created event Location's show_in_spoiler attribute.
+ :return: The created Event Item
+ """
+ if location_type is None:
+ location_type = Location
+
+ if item_name is None:
+ item_name = location_name
+
+ if item_type is None:
+ item_type = Item
+
+ event_location = location_type(self.player, location_name, None, self)
+ event_location.show_in_spoiler = show_in_spoiler
+ if rule is not None:
+ event_location.access_rule = rule
+
+ event_item = item_type(item_name, ItemClassification.progression, None, self.player)
+
+ event_location.place_locked_item(event_item)
+
+ self.locations.append(event_location)
+
+ return event_item
+
def connect(self, connecting_region: Region, name: Optional[str] = None,
- rule: Optional[Callable[[CollectionState], bool]] = None) -> None:
+ rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
"""
Connects this Region to another Region, placing the provided rule on the connection.
-
+
:param connecting_region: Region object to connect to path is `self -> exiting_region`
:param name: name of the connection being created
:param rule: callable to determine access of this connection to go from self to the exiting_region"""
@@ -897,37 +1412,49 @@ def connect(self, connecting_region: Region, name: Optional[str] = None,
if rule:
exit_.access_rule = rule
exit_.connect(connecting_region)
-
+ return exit_
+
def create_exit(self, name: str) -> Entrance:
"""
Creates and returns an Entrance object as an exit of this region.
-
+
:param name: name of the Entrance being created
"""
exit_ = self.entrance_type(self.player, name, self)
self.exits.append(exit_)
return exit_
- def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
- rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None:
+ def create_er_target(self, name: str) -> Entrance:
+ """
+ Creates and returns an Entrance object as an entrance to this region
+
+ :param name: name of the Entrance being created
+ """
+ entrance = self.entrance_type(self.player, name)
+ entrance.connect(self)
+ return entrance
+
+ def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
+ rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]:
"""
Connects current region to regions in exit dictionary. Passed region names must exist first.
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
- created entrances will be named "self.name -> connecting_region"
- :param rules: rules for the exits from this region. format is {"connecting_region", rule}
+ created entrances will be named "self.name -> connecting_region"
+ :param rules: rules for the exits from this region. format is {"connecting_region": rule}
"""
- if not isinstance(exits, Dict):
+ if not isinstance(exits, Mapping):
exits = dict.fromkeys(exits)
- for connecting_region, name in exits.items():
- self.connect(self.multiworld.get_region(connecting_region, self.player),
- name,
- rules[connecting_region] if rules and connecting_region in rules else None)
+ return [
+ self.connect(
+ self.multiworld.get_region(connecting_region, self.player),
+ name,
+ rules[connecting_region] if rules and connecting_region in rules else None,
+ )
+ for connecting_region, name in exits.items()
+ ]
def __repr__(self):
- return self.__str__()
-
- def __str__(self):
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
@@ -943,13 +1470,12 @@ class Location:
name: str
address: Optional[int]
parent_region: Optional[Region]
- event: bool = False
locked: bool = False
show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT
- always_allow = staticmethod(lambda item, state: False)
+ always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False)
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
- item_rule = staticmethod(lambda item: True)
+ item_rule: Callable[[Item], bool] = staticmethod(lambda item: True)
item: Optional[Item] = None
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
@@ -958,64 +1484,96 @@ def __init__(self, player: int, name: str = '', address: Optional[int] = None, p
self.address = address
self.parent_region = parent
- def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
- 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))))
+ def can_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool:
+ return ((
+ self.always_allow(state, item)
+ and item.name not in state.multiworld.worlds[item.player].options.non_local_items
+ ) 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))
+ ))
def can_reach(self, state: CollectionState) -> bool:
- # self.access_rule computes faster on average, so placing it first for faster abort
- assert self.parent_region, "Can't reach location without region"
- return self.access_rule(state) and self.parent_region.can_reach(state)
+ # Region.can_reach is just a cache lookup, so placing it first for faster abort on average
+ assert self.parent_region, f"called can_reach on a Location \"{self}\" with no parent_region"
+ return self.parent_region.can_reach(state) and self.access_rule(state)
def place_locked_item(self, item: Item):
if self.item:
raise Exception(f"Location {self} already filled.")
self.item = item
item.location = self
- self.event = item.advancement
self.locked = True
def __repr__(self):
- return self.__str__()
-
- def __str__(self):
- world = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
- return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
-
- def __hash__(self):
- return hash((self.name, self.player))
+ multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
+ return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
def __lt__(self, other: Location):
return (self.player, self.name) < (other.player, other.name)
+ @property
+ def advancement(self) -> bool:
+ return self.item is not None and self.item.advancement
+
+ @property
+ def is_event(self) -> bool:
+ """Returns True if the address of this location is None, denoting it is an Event Location."""
+ return self.address is None
+
@property
def native_item(self) -> bool:
"""Returns True if the item in this location matches game."""
- return self.item and self.item.game == self.game
+ return self.item is not None and self.item.game == self.game
@property
def hint_text(self) -> str:
- hint_text = getattr(self, "_hint_text", None)
- if hint_text:
- return hint_text
return "at " + self.name.replace("_", " ").replace("-", " ")
class ItemClassification(IntFlag):
- filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
- progression = 0b0001 # Item that is logically relevant
- useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
- trap = 0b0100 # detrimental or entirely useless (nothing) item
- skip_balancing = 0b1000 # should technically never occur on its own
- # Item that is logically relevant, but progression balancing should not touch.
- # Typically currency or other counted items.
- progression_skip_balancing = 0b1001 # only progression gets balanced
+ filler = 0b00000
+ """ aka trash, as in filler items like ammo, currency etc """
+
+ progression = 0b00001
+ """ Item that is logically relevant.
+ Protects this item from being placed on excluded or unreachable locations. """
+
+ useful = 0b00010
+ """ Item that is especially useful.
+ Protects this item from being placed on excluded or unreachable locations.
+ When combined with another flag like "progression", it means "an especially useful progression item". """
+
+ trap = 0b00100
+ """ Item that is detrimental in some way. """
+
+ skip_balancing = 0b01000
+ """ should technically never occur on its own
+ Item that is logically relevant, but progression balancing should not touch.
+
+ Possible reasons for why an item should not be pulled ahead by progression balancing:
+ 1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.)
+ 2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """
+
+ deprioritized = 0b10000
+ """ Should technically never occur on its own.
+ Will not be considered for priority locations,
+ unless Priority Locations Fill runs out of regular progression items before filling all priority locations.
+
+ Should be used for items that would feel bad for the player to find on a priority location.
+ Usually, these are items that are plentiful or insignificant. """
+
+ progression_deprioritized_skip_balancing = 0b11001
+ """ Since a common case of both skip_balancing and deprioritized is "insignificant progression",
+ these items often want both flags. """
+
+ progression_skip_balancing = 0b01001 # only progression gets balanced
+ progression_deprioritized = 0b10001 # only progression can be placed during priority fill
def as_flag(self) -> int:
"""As Network API flag int."""
- return int(self & 0b0111)
+ return int(self & 0b00111)
class Item:
@@ -1059,10 +1617,26 @@ def useful(self) -> bool:
def trap(self) -> bool:
return ItemClassification.trap in self.classification
+ @property
+ def deprioritized(self) -> bool:
+ return ItemClassification.deprioritized in self.classification
+
+ @property
+ def filler(self) -> bool:
+ return not (self.advancement or self.useful or self.trap)
+
+ @property
+ def excludable(self) -> bool:
+ return not (self.advancement or self.useful)
+
@property
def flags(self) -> int:
return self.classification.as_flag()
+ @property
+ def is_event(self) -> bool:
+ return self.code is None
+
def __eq__(self, other: object) -> bool:
if not isinstance(other, Item):
return NotImplemented
@@ -1079,9 +1653,6 @@ def __hash__(self) -> int:
return hash((self.name, self.player))
def __repr__(self) -> str:
- return self.__str__()
-
- def __str__(self) -> str:
if self.location and self.location.parent_region and self.location.parent_region.multiworld:
return self.location.parent_region.multiworld.get_name_string_for_object(self)
return f"{self.name} (Player {self.player})"
@@ -1119,7 +1690,7 @@ def set_entrance(self, entrance: str, exit_: str, direction: str, player: int) -
{"player": player, "entrance": entrance, "exit": exit_, "direction": direction}
def create_playthrough(self, create_paths: bool = True) -> None:
- """Destructive to the world while it is run, damage gets repaired afterwards."""
+ """Destructive to the multiworld while it is run, damage gets repaired afterwards."""
from itertools import chain
# get locations containing progress items
multiworld = self.multiworld
@@ -1150,44 +1721,50 @@ def create_playthrough(self, create_paths: bool = True) -> None:
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.')
+ if not multiworld.has_beaten_game(state):
+ raise RuntimeError("During playthrough generation, the game was determined to be unbeatable. "
+ "Something went terribly wrong here. "
+ f"Unreachable progression items: {sphere_candidates}")
else:
self.unreachables = sphere_candidates
break
# 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 = {}
+ required_locations = {location for sphere in collection_spheres for location in sphere}
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
- to_delete = set()
+ to_delete: Set[Location] = set()
for location in sphere:
- # we remove the item at location and check if game is still beatable
+ # we remove the location from required_locations to sweep from, and check if the game is still beatable
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
location.item.player)
- old_item = location.item
- location.item = None
- if multiworld.can_beat_game(state_cache[num]):
+ required_locations.remove(location)
+ if multiworld.can_beat_game(state_cache[num], required_locations):
to_delete.add(location)
- restore_later[location] = old_item
else:
# still required, got to keep it around
- location.item = old_item
+ required_locations.add(location)
# 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(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:
- removed_precollected.append(item)
+ removed_precollected: List[Item] = []
+
+ for precollected_items in multiworld.precollected_items.values():
+ # The list of items is mutated by removing one item at a time to determine if each item is required to beat
+ # the game, and re-adding that item if it was required, so a copy needs to be made before iterating.
+ for item in precollected_items.copy():
+ if not item.advancement:
+ continue
+ logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
+ precollected_items.remove(item)
+ multiworld.state.remove(item)
+ if not multiworld.can_beat_game(multiworld.state, required_locations):
+ # Add the item back into `precollected_items` and collect it into `multiworld.state`.
+ multiworld.push_precollected(item)
+ else:
+ removed_precollected.append(item)
# we are now down to just the required progress items in collection_spheres. Unfortunately
# the previous pruning stage could potentially have made certain items dependant on others
@@ -1199,19 +1776,17 @@ def create_playthrough(self, create_paths: bool = True) -> None:
state = CollectionState(multiworld)
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))
+
+ required_locations -= sphere
if not sphere:
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
@@ -1227,9 +1802,6 @@ def create_playthrough(self, create_paths: bool = True) -> None:
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)
@@ -1262,7 +1834,7 @@ def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, st
# 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':
+ if multiworld.worlds[player].options.mode != 'inverted':
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
get_path(state, multiworld.get_region('Big Bomb Shop', player))
else:
@@ -1270,10 +1842,15 @@ def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, st
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
def to_file(self, filename: str) -> None:
+ from itertools import chain
+ from worlds import AutoWorld
+ from Options import Visibility
+
def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
- res = getattr(self.multiworld, option_key)[player]
- display_name = getattr(option_obj, "display_name", option_key)
- outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
+ res = getattr(self.multiworld.worlds[player].options, option_key)
+ if res.visibility & Visibility.spoiler:
+ display_name = getattr(option_obj, "display_name", option_key)
+ outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
with open(filename, 'w', encoding="utf-8-sig") as outfile:
outfile.write(
@@ -1281,6 +1858,9 @@ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
Utils.__version__, self.multiworld.seed))
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
outfile.write('Players: %d\n' % self.multiworld.players)
+ if self.multiworld.players > 1:
+ loc_count = len([loc for loc in self.multiworld.get_locations() if not loc.is_event])
+ outfile.write('Total Location Count: %d\n' % loc_count)
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
@@ -1289,8 +1869,10 @@ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player])
- options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions)
- for f_option, option in options.items():
+ loc_count = len([loc for loc in self.multiworld.get_locations(player) if not loc.is_event])
+ outfile.write('Location Count: %d\n' % loc_count)
+
+ for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
write_option(f_option, option)
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)
@@ -1305,6 +1887,14 @@ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
+ precollected_items = [f"{item.name} ({self.multiworld.get_player_name(item.player)})"
+ if self.multiworld.players > 1
+ else item.name
+ for item in chain.from_iterable(self.multiworld.precollected_items.values())]
+ if precollected_items:
+ outfile.write("\n\nStarting Items:\n\n")
+ outfile.write("\n".join([item for item in precollected_items]))
+
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')
@@ -1316,15 +1906,16 @@ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
[f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else
[f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
if self.unreachables:
- outfile.write('\n\nUnreachable Items:\n\n')
+ outfile.write('\n\nUnreachable Progression Items:\n\n')
outfile.write(
- '\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
+ '\n'.join(['%s: %s' % (unreachable.item, unreachable)
+ for unreachable in sorted(self.unreachables)]))
if self.paths:
outfile.write('\n\nPaths:\n\n')
- path_listings = []
+ path_listings: List[str] = []
for location, path in sorted(self.paths.items()):
- path_lines = []
+ path_lines: List[str] = []
for region, exit in path:
if exit is not None:
path_lines.append("{} -> {}".format(region, exit))
@@ -1345,7 +1936,7 @@ class Tutorial(NamedTuple):
description: str
language: str
file_name: str
- link: str
+ link: str # unused
authors: List[str]
@@ -1394,8 +1985,3 @@ def get_seed(seed: Optional[int] = None) -> int:
random.seed(None)
return random.randint(0, pow(10, seeddigits) - 1)
return seed
-
-
-from worlds import AutoWorld
-
-auto_world = AutoWorld.World
diff --git a/BizHawkClient.py b/BizHawkClient.py
new file mode 100644
index 000000000000..743785b25f16
--- /dev/null
+++ b/BizHawkClient.py
@@ -0,0 +1,10 @@
+from __future__ import annotations
+
+import sys
+import ModuleUpdate
+ModuleUpdate.update()
+
+from worlds._bizhawk.context import launch
+
+if __name__ == "__main__":
+ launch(*sys.argv[1:])
diff --git a/CommonClient.py b/CommonClient.py
old mode 100644
new mode 100755
index 61fad6589793..1111adb080cc
--- a/CommonClient.py
+++ b/CommonClient.py
@@ -1,4 +1,7 @@
from __future__ import annotations
+
+import collections
+import copy
import logging
import asyncio
import urllib.parse
@@ -6,6 +9,7 @@
import typing
import time
import functools
+import warnings
import ModuleUpdate
ModuleUpdate.update()
@@ -17,9 +21,9 @@
if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client")
-from MultiServer import CommandProcessor
-from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
- ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
+from MultiServer import CommandProcessor, mark_raw
+from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
+ RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister
import os
@@ -27,6 +31,7 @@
if typing.TYPE_CHECKING:
import kvui
+ import argparse
logger = logging.getLogger("Client")
@@ -41,10 +46,21 @@ def get_ssl_context():
class ClientCommandProcessor(CommandProcessor):
+ """
+ The Command Processor will parse every method of the class that starts with "_cmd_" as a command to be called
+ when parsing user input, i.e. _cmd_exit will be called when the user sends the command "/exit".
+
+ The decorator @mark_raw can be imported from MultiServer and tells the parser to only split on the first
+ space after the command i.e. "/exit one two three" will be passed in as method("one two three") with mark_raw
+ and method("one", "two", "three") without.
+
+ In addition all docstrings for command methods will be displayed to the user on launch and when using "/help"
+ """
def __init__(self, ctx: CommonContext):
self.ctx = ctx
def output(self, text: str):
+ """Helper function to abstract logging to the CommonClient UI"""
logger.info(text)
def _cmd_exit(self) -> bool:
@@ -57,6 +73,7 @@ def _cmd_connect(self, address: str = "") -> bool:
if address:
self.ctx.server_address = None
self.ctx.username = None
+ self.ctx.password = None
elif not self.ctx.server_address:
self.output("Please specify an address.")
return False
@@ -70,9 +87,16 @@ def _cmd_disconnect(self) -> bool:
def _cmd_received(self) -> bool:
"""List all received items"""
- self.output(f'{len(self.ctx.items_received)} received items:')
+ item: NetworkItem
+ self.output(f'{len(self.ctx.items_received)} received items, sorted by time:')
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]}")
+ parts = []
+ add_json_item(parts, item.item, self.ctx.slot, item.flags)
+ add_json_text(parts, " from ")
+ add_json_location(parts, item.location, item.player)
+ add_json_text(parts, " by ")
+ add_json_text(parts, item.player, type=JSONTypes.player_id)
+ self.ctx.on_print_json({"data": parts, "cmd": "PrintJSON"})
return True
def _cmd_missing(self, filter_text = "") -> bool:
@@ -83,7 +107,9 @@ def _cmd_missing(self, filter_text = "") -> bool:
return False
count = 0
checked_count = 0
- for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
+
+ lookup = self.ctx.location_names[self.ctx.game]
+ for location_id, location in lookup.items():
if filter_text and filter_text not in location:
continue
if location_id < 0:
@@ -104,25 +130,87 @@ def _cmd_missing(self, filter_text = "") -> bool:
self.output("No missing location checks found.")
return True
- def _cmd_items(self):
- """List all item names for the currently running game."""
+ def output_datapackage_part(self, name: typing.Literal["Item Names", "Location Names"]) -> bool:
+ """
+ Helper to digest a specific section of this game's datapackage.
+
+ :param name: Printed to the user as context for the part.
+
+ :return: Whether the process was successful.
+ """
if not self.ctx.game:
- self.output("No game set, cannot determine existing items.")
+ self.output(f"No game set, cannot determine {name}.")
return False
- self.output(f"Item Names for {self.ctx.game}")
- for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
- self.output(item_name)
- def _cmd_locations(self):
+ lookup = self.ctx.item_names if name == "Item Names" else self.ctx.location_names
+ lookup = lookup[self.ctx.game]
+ self.output(f"{name} for {self.ctx.game}")
+ for name in lookup.values():
+ self.output(name)
+ return True
+
+ def _cmd_items(self) -> bool:
+ """List all item names for the currently running game."""
+ return self.output_datapackage_part("Item Names")
+
+ def _cmd_locations(self) -> bool:
"""List all location names for the currently running game."""
+ return self.output_datapackage_part("Location Names")
+
+ def output_group_part(self, group_key: typing.Literal["item_name_groups", "location_name_groups"],
+ filter_key: str,
+ name: str) -> bool:
+ """
+ Logs an item or location group from the player's game's datapackage.
+
+ :param group_key: Either Item or Location group to be processed.
+ :param filter_key: Which group key to filter to. If an empty string is passed will log all item/location groups.
+ :param name: Printed to the user as context for the part.
+
+ :return: Whether the process was successful.
+ """
if not self.ctx.game:
- self.output("No game set, cannot determine existing locations.")
+ self.output(f"No game set, cannot determine existing {name} Groups.")
+ return False
+ lookup = Utils.persistent_load().get("groups_by_checksum", {}).get(self.ctx.checksums[self.ctx.game], {})\
+ .get(self.ctx.game, {}).get(group_key, {})
+ if lookup is None:
+ self.output("datapackage not yet loaded, try again")
return False
- self.output(f"Location Names for {self.ctx.game}")
- for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
- self.output(location_name)
- def _cmd_ready(self):
+ if filter_key:
+ if filter_key not in lookup:
+ self.output(f"Unknown {name} Group {filter_key}")
+ return False
+
+ self.output(f"{name}s for {name} Group \"{filter_key}\"")
+ for entry in lookup[filter_key]:
+ self.output(entry)
+ else:
+ self.output(f"{name} Groups for {self.ctx.game}")
+ for group in lookup:
+ self.output(group)
+ return True
+
+ @mark_raw
+ def _cmd_item_groups(self, key: str = "") -> bool:
+ """
+ List all item group names for the currently running game.
+
+ :param key: Which item group to filter to. Will log all groups if empty.
+ """
+ return self.output_group_part("item_name_groups", key, "Item")
+
+ @mark_raw
+ def _cmd_location_groups(self, key: str = "") -> bool:
+ """
+ List all location group names for the currently running game.
+
+ :param key: Which item group to filter to. Will log all groups if empty.
+ """
+ return self.output_group_part("location_name_groups", key, "Location")
+
+ def _cmd_ready(self) -> bool:
"""Send ready status to server."""
self.ctx.ready = not self.ctx.ready
if self.ctx.ready:
@@ -132,30 +220,85 @@ def _cmd_ready(self):
state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.")
async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
+ return True
def default(self, raw: str):
+ """The default message parser to be used when parsing any messages that do not match a command"""
raw = self.ctx.on_user_say(raw)
if raw:
async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext:
- # Should be adjusted as needed in subclasses
+ # The following attributes are used to Connect and should be adjusted as needed in subclasses
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
- # data package
- # Contents in flux until connection to server is made, to download correct data for this multiworld.
- item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
- location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
+ class NameLookupDict:
+ """A specialized dict, with helper methods, for id -> name item/location data package lookups by game."""
+ def __init__(self, ctx: CommonContext, lookup_type: typing.Literal["item", "location"]):
+ self.ctx: CommonContext = ctx
+ self.lookup_type: typing.Literal["item", "location"] = lookup_type
+ self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
+ self._archipelago_lookup: typing.Dict[int, str] = {}
+ self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
+ lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
+
+ # noinspection PyTypeChecker
+ def __getitem__(self, key: str) -> typing.Mapping[int, str]:
+ assert isinstance(key, str), f"ctx.{self.lookup_type}_names used with an id, use the lookup_in_ helpers instead"
+ return self._game_store[key]
+
+ def __len__(self) -> int:
+ return len(self._game_store)
+
+ def __iter__(self) -> typing.Iterator[str]:
+ return iter(self._game_store)
+
+ def __repr__(self) -> str:
+ return repr(self._game_store)
+
+ def lookup_in_game(self, code: int, game_name: typing.Optional[str] = None) -> str:
+ """Returns the name for an item/location id in the context of a specific game or own game if `game` is
+ omitted.
+ """
+ if game_name is None:
+ game_name = self.ctx.game
+ assert game_name is not None, f"Attempted to lookup {self.lookup_type} with no game name available."
+
+ return self._game_store[game_name][code]
+
+ def lookup_in_slot(self, code: int, slot: typing.Optional[int] = None) -> str:
+ """Returns the name for an item/location id in the context of a specific slot or own slot if `slot` is
+ omitted.
+
+ Use of `lookup_in_slot` should not be used when not connected to a server. If looking in own game, set
+ `ctx.game` and use `lookup_in_game` method instead.
+ """
+ if slot is None:
+ slot = self.ctx.slot
+ assert slot is not None, f"Attempted to lookup {self.lookup_type} with no slot info available."
+
+ return self.lookup_in_game(code, self.ctx.slot_info[slot].game)
+
+ def update_game(self, game: str, name_to_id_lookup_table: typing.Dict[str, int]) -> None:
+ """Overrides existing lookup tables for a particular game."""
+ id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
+ id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
+ self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
+ if game == "Archipelago":
+ # Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
+ # it updates in all chain maps automatically.
+ self._archipelago_lookup.clear()
+ self._archipelago_lookup.update(id_to_name_lookup_table)
# defaults
starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay
command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
- ui = None
+ ui: typing.Optional["kvui.GameManager"] = None
ui_task: typing.Optional["asyncio.Task[None]"] = None
input_task: typing.Optional["asyncio.Task[None]"] = None
keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
@@ -166,42 +309,78 @@ class CommonContext:
server_version: Version = Version(0, 0, 0)
generator_version: Version = Version(0, 0, 0)
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
+ max_size: int = 16*1024*1024 # 16 MB of max incoming packet size
last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info
- slot_info: typing.Dict[int, NetworkSlot]
- server_address: typing.Optional[str]
- password: typing.Optional[str]
- hint_cost: typing.Optional[int]
- hint_points: typing.Optional[int]
- player_names: typing.Dict[int, str]
+ slot_info: dict[int, NetworkSlot]
+ """Slot Info from the server for the current connection"""
+ server_address: str | None
+ """Autoconnect address provided by the ctx constructor"""
+ password: str | None
+ """Password used for Connecting, expected by server_auth"""
+ hint_cost: int | None
+ """Current Hint Cost per Hint from the server"""
+ hint_points: int | None
+ """Current available Hint Points from the server"""
+ player_names: dict[int, str]
+ """Current lookup of slot number to player display name from server (includes aliases)"""
finished_game: bool
+ """
+ Bool to signal that status should be updated to Goal after reconnecting
+ to be used to ensure that a StatusUpdate packet does not get lost when disconnected
+ """
ready: bool
- auth: typing.Optional[str]
- seed_name: typing.Optional[str]
+ """Bool to keep track of state for the /ready command"""
+ team: int | None
+ """Team number of currently connected slot"""
+ slot: int | None
+ """Slot number of currently connected slot"""
+ auth: str | None
+ """Name used in Connect packet"""
+ seed_name: str | None
+ """Seed name that will be validated on opening a socket if present"""
# locations
- locations_checked: typing.Set[int] # local state
- locations_scouted: typing.Set[int]
- items_received: typing.List[NetworkItem]
- missing_locations: typing.Set[int] # server state
- checked_locations: typing.Set[int] # server state
- server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
- locations_info: typing.Dict[int, NetworkItem]
+ locations_checked: set[int]
+ """
+ Local container of location ids checked to signal that LocationChecks should be resent after reconnecting
+ to be used to ensure that a LocationChecks packet does not get lost when disconnected
+ """
+ locations_scouted: set[int]
+ """
+ Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
+ to be used to ensure that a LocationScouts packet does not get lost when disconnected
+ """
+ items_received: list[NetworkItem]
+ """List of NetworkItems recieved from the server"""
+ missing_locations: set[int]
+ """Container of Locations that are unchecked per server state"""
+ checked_locations: set[int]
+ """Container of Locations that are checked per server state"""
+ server_locations: set[int]
+ """Container of Locations that exist per server state; a combination between missing and checked locations"""
+ locations_info: dict[int, NetworkItem]
+ """Dict of location id: NetworkItem info from LocationScouts request"""
# data storage
- stored_data: typing.Dict[str, typing.Any]
- stored_data_notification_keys: typing.Set[str]
+ stored_data: dict[str, typing.Any]
+ """
+ Data Storage values by key that were retrieved from the server
+ any keys subscribed to with SetNotify will be kept up to date
+ """
+ stored_data_notification_keys: set[str]
+ """Current container of watched Data Storage keys, managed by ctx.set_notify"""
# internals
- # current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None
- # message box reporting a loss of connection
+ """Current message box through kvui"""
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
+ """Message box reporting a loss of connection"""
- def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
+ def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
# server state
self.server_address = server_address
self.username = None
@@ -241,7 +420,14 @@ def __init__(self, server_address: typing.Optional[str], password: typing.Option
self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event()
+ self.item_names = self.NameLookupDict(self, "item")
+ self.location_names = self.NameLookupDict(self, "location")
+ self.checksums = {}
+
self.jsontotextparser = JSONtoTextParser(self)
+ self.rawjsontotextparser = RawJSONtoTextParser(self)
+ if self.game:
+ self.checksums[self.game] = network_data_package["games"][self.game]["checksum"]
self.update_data_package(network_data_package)
# execution
@@ -294,6 +480,8 @@ async def disconnect(self, allow_autoreconnect: bool = False):
await self.server.socket.close()
if self.server_task is not None:
await self.server_task
+ if self.ui:
+ self.ui.update_hints()
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
""" `msgs` JSON serializable """
@@ -325,7 +513,10 @@ async def get_username(self):
self.auth = await self.console_input()
async def send_connect(self, **kwargs: typing.Any) -> None:
- """ send `Connect` packet to log in to server """
+ """
+ Send a `Connect` packet to log in to the server,
+ additional keyword args can override any value in the connection packet
+ """
payload = {
'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
@@ -335,6 +526,14 @@ async def send_connect(self, **kwargs: typing.Any) -> None:
if kwargs:
payload.update(kwargs)
await self.send_msgs([payload])
+ await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
+
+ async def check_locations(self, locations: typing.Collection[int]) -> set[int]:
+ """Send new location checks to the server. Returns the set of actually new locations that were sent."""
+ locations = set(locations) & self.missing_locations
+ if locations:
+ await self.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(locations)}])
+ return locations
async def console_input(self) -> str:
if self.ui:
@@ -355,6 +554,7 @@ def cancel_autoreconnect(self) -> bool:
return False
def slot_concerns_self(self, slot) -> bool:
+ """Helper function to abstract player groups, should be used instead of checking slot == self.slot directly."""
if slot == self.slot:
return True
if slot in self.slot_info:
@@ -362,6 +562,7 @@ def slot_concerns_self(self, slot) -> bool:
return False
def is_echoed_chat(self, print_json_packet: dict) -> bool:
+ """Helper function for filtering out messages sent by self."""
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
@@ -371,16 +572,23 @@ def is_uninteresting_item_send(self, print_json_packet: dict) -> bool:
return print_json_packet.get("type", "") == "ItemSend" \
and not self.slot_concerns_self(print_json_packet["receiving"]) \
and not self.slot_concerns_self(print_json_packet["item"].player)
+
+ def is_connection_change(self, print_json_packet: dict) -> bool:
+ """Helper function for filtering out connection changes."""
+ return print_json_packet.get("type", "") in ["Join","Part"]
def on_print(self, args: dict):
logger.info(args["text"])
def on_print_json(self, args: dict):
if self.ui:
- self.ui.print_json(args["data"])
- else:
- text = self.jsontotextparser(args["data"])
- logger.info(text)
+ # send copy to UI
+ self.ui.print_json(copy.deepcopy(args["data"]))
+
+ logging.getLogger("FileLog").info(self.rawjsontotextparser(copy.deepcopy(args["data"])),
+ extra={"NoStream": True})
+ logging.getLogger("StreamLog").info(self.jsontotextparser(copy.deepcopy(args["data"])),
+ extra={"NoFile": True})
def on_package(self, cmd: str, args: dict):
"""For custom package handling in subclasses."""
@@ -391,7 +599,13 @@ def on_user_say(self, text: str) -> typing.Optional[str]:
Returned text is sent, or sending is aborted if None is returned."""
return text
+ def on_ui_command(self, text: str) -> None:
+ """Gets called by kivy when the user executes a command starting with `/` or `!`.
+ The command processor is still called; this is just intended for command echoing."""
+ self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
+
def update_permissions(self, permissions: typing.Dict[str, int]):
+ """Internal method to parse and save server permissions from RoomInfo"""
for permission_name, permission_flag in permissions.items():
try:
flag = Permission(permission_flag)
@@ -403,6 +617,7 @@ def update_permissions(self, permissions: typing.Dict[str, int]):
async def shutdown(self):
self.server_address = ""
self.username = None
+ self.password = None
self.cancel_autoreconnect()
if self.server and not self.server.socket.closed:
await self.server.socket.close()
@@ -417,10 +632,16 @@ async def shutdown(self):
await self.ui_task
if self.input_task:
self.input_task.cancel()
-
+
+ # Hints
+ def update_hint(self, location: int, finding_player: int, status: typing.Optional[HintStatus]) -> None:
+ msg = {"cmd": "UpdateHint", "location": location, "player": finding_player}
+ if status is not None:
+ msg["status"] = status
+ async_start(self.send_msgs([msg]), name="update_hint")
+
# DataPackage
async def prepare_data_package(self, relevant_games: typing.Set[str],
- remote_date_package_versions: typing.Dict[str, int],
remote_data_package_checksums: typing.Dict[str, str]):
"""Validate that all data is present for the current multiworld.
Download, assimilate and cache missing data from the server."""
@@ -429,51 +650,65 @@ async def prepare_data_package(self, relevant_games: typing.Set[str],
needed_updates: typing.Set[str] = set()
for game in relevant_games:
- if game not in remote_date_package_versions and game not in remote_data_package_checksums:
+ if game not in remote_data_package_checksums:
continue
- remote_version: int = remote_date_package_versions.get(game, 0)
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
- if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
+ if not remote_checksum: # custom data package and no checksum for this game
needed_updates.add(game)
continue
- local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
- local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
- # no action required if local version is new enough
- if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
- or remote_checksum != local_checksum:
- cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
- cache_version: int = cached_game.get("version", 0)
- cache_checksum: typing.Optional[str] = cached_game.get("checksum")
- # download remote version if cache is not new enough
- if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
- or remote_checksum != cache_checksum:
- needed_updates.add(game)
+ cached_checksum: typing.Optional[str] = self.checksums.get(game)
+ # no action required if cached version is new enough
+ if remote_checksum != cached_checksum:
+ local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
+ if remote_checksum == local_checksum:
+ self.update_game(network_data_package["games"][game], game)
else:
- self.update_game(cached_game)
+ cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
+ cache_checksum: typing.Optional[str] = cached_game.get("checksum")
+ # download remote version if cache is not new enough
+ if remote_checksum != cache_checksum:
+ needed_updates.add(game)
+ else:
+ self.update_game(cached_game, game)
if needed_updates:
- await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
+ await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
- def update_game(self, game_package: dict):
- for item_name, item_id in game_package["item_name_to_id"].items():
- self.item_names[item_id] = item_name
- for location_name, location_id in game_package["location_name_to_id"].items():
- self.location_names[location_id] = location_name
+ def update_game(self, game_package: dict, game: str):
+ self.item_names.update_game(game, game_package["item_name_to_id"])
+ self.location_names.update_game(game, game_package["location_name_to_id"])
+ self.checksums[game] = game_package.get("checksum")
def update_data_package(self, data_package: dict):
for game, game_data in data_package["games"].items():
- self.update_game(game_data)
+ self.update_game(game_data, game)
def consume_network_data_package(self, data_package: dict):
self.update_data_package(data_package)
- current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
- current_cache.update(data_package["games"])
- Utils.persistent_store("datapackage", "games", current_cache)
+ logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data)
+ def consume_network_item_groups(self):
+ data = {"item_name_groups": self.stored_data[f"_read_item_name_groups_{self.game}"]}
+ current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
+ if self.game in current_cache:
+ current_cache[self.game].update(data)
+ else:
+ current_cache[self.game] = data
+ Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
+
+ def consume_network_location_groups(self):
+ data = {"location_name_groups": self.stored_data[f"_read_location_name_groups_{self.game}"]}
+ current_cache = Utils.persistent_load().get("groups_by_checksum", {}).get(self.checksums[self.game], {})
+ if self.game in current_cache:
+ current_cache[self.game].update(data)
+ else:
+ current_cache[self.game] = data
+ Utils.persistent_store("groups_by_checksum", self.checksums[self.game], current_cache)
+
# data storage
def set_notify(self, *keys: str) -> None:
@@ -501,6 +736,7 @@ def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
logger.info(f"DeathLink: Received from {data['source']}")
async def send_death(self, death_text: str = ""):
+ """Helper function to send a deathlink using death_text as the unique death cause string."""
if self.server and self.server.socket:
logger.info("DeathLink: Sending death to your friends...")
self.last_death_link = time.time()
@@ -514,6 +750,7 @@ async def send_death(self, death_text: str = ""):
}])
async def update_death_link(self, death_link: bool):
+ """Helper function to set Death Link connection tag on/off and update the connection if already connected."""
old_tags = self.tags.copy()
if death_link:
self.tags.add("DeathLink")
@@ -523,7 +760,7 @@ async def update_death_link(self, death_link: bool):
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
- """Displays an error messagebox"""
+ """Displays an error messagebox in the loaded Kivy UI. Override if using a different UI framework"""
if not self.ui:
return None
title = title or "Error"
@@ -550,21 +787,36 @@ def handle_connection_loss(self, msg: str) -> None:
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
- def run_gui(self):
- """Import kivy UI system and start running it as self.ui_task."""
+ def make_gui(self) -> "type[kvui.GameManager]":
+ """
+ To return the Kivy `App` class needed for `run_gui` so it can be overridden before being built
+
+ Common changes are changing `base_title` to update the window title of the client and
+ updating `logging_pairs` to automatically make new tabs that can be filled with their respective logger.
+
+ ex. `logging_pairs.append(("Foo", "Bar"))`
+ will add a "Bar" tab which follows the logger returned from `logging.getLogger("Foo")`
+ """
from kvui import GameManager
class TextManager(GameManager):
- logging_pairs = [
- ("Client", "Archipelago")
- ]
base_title = "Archipelago Text Client"
- self.ui = TextManager(self)
+ return TextManager
+
+ def run_gui(self):
+ """Import kivy UI system from make_gui() and start running it as self.ui_task."""
+ ui_class = self.make_gui()
+ self.ui = ui_class(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def run_cli(self):
if sys.stdin:
+ if sys.stdin.fileno() != 0:
+ from multiprocessing import parent_process
+ if parent_process():
+ return # ignore MultiProcessing pipe
+
# steam overlay breaks when starting console_loop
if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''):
logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.")
@@ -608,18 +860,19 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
server_url = urllib.parse.urlparse(address)
if server_url.username:
- ctx.username = server_url.username
+ ctx.username = urllib.parse.unquote(server_url.username)
if server_url.password:
- ctx.password = server_url.password
- port = server_url.port or 38281
+ ctx.password = urllib.parse.unquote(server_url.password)
def reconnect_hint() -> str:
return ", type /connect to reconnect" if ctx.server_address else ""
logger.info(f'Connecting to Archipelago server at {address}')
try:
+ port = server_url.port or 38281 # raises ValueError if invalid
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
- ssl=get_ssl_context() if address.startswith("wss://") else None)
+ ssl=get_ssl_context() if address.startswith("wss://") else None,
+ max_size=ctx.max_size)
if ctx.ui is not None:
ctx.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket)
@@ -714,24 +967,26 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
# update data package
- data_package_versions = args.get("datapackage_versions", {})
data_package_checksums = args.get("datapackage_checksums", {})
- await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
+ await ctx.prepare_data_package(set(args["games"]), data_package_checksums)
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
- logger.info("Got new ID/Name DataPackage")
ctx.consume_network_data_package(args['data'])
elif cmd == 'ConnectionRefused':
errors = args["errors"]
if 'InvalidSlot' in errors:
+ ctx.disconnected_intentionally = True
ctx.event_invalid_slot()
elif 'InvalidGame' in errors:
+ ctx.disconnected_intentionally = True
ctx.event_invalid_game()
elif 'IncompatibleVersion' in errors:
- raise Exception('Server reported your client version as incompatible')
+ ctx.disconnected_intentionally = True
+ raise Exception('Server reported your client version as incompatible. '
+ 'This probably means you have to update.')
elif 'InvalidItemsHandling' in errors:
raise Exception('The item handling flags requested by the client are not supported')
# last to check, recoverable problem
@@ -749,9 +1004,17 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.team = args["team"]
ctx.slot = args["slot"]
# int keys get lost in JSON transfer
- ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
+ ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)}
+ ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()})
ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"])
+ ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
+ if ctx.game:
+ game = ctx.game
+ else:
+ game = ctx.slot_info[ctx.slot][1]
+ ctx.stored_data_notification_keys.add(f"_read_item_name_groups_{game}")
+ ctx.stored_data_notification_keys.add(f"_read_location_name_groups_{game}")
msgs = []
if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks",
@@ -830,10 +1093,22 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == "Retrieved":
ctx.stored_data.update(args["keys"])
+ if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
+ ctx.ui.update_hints()
+ if f"_read_item_name_groups_{ctx.game}" in args["keys"]:
+ ctx.consume_network_item_groups()
+ if f"_read_location_name_groups_{ctx.game}" in args["keys"]:
+ ctx.consume_network_location_groups()
elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"]
- if args["key"].startswith("EnergyLink"):
+ if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
+ ctx.ui.update_hints()
+ elif f"_read_item_name_groups_{ctx.game}" == args["key"]:
+ ctx.consume_network_item_groups()
+ elif f"_read_location_name_groups_{ctx.game}" == args["key"]:
+ ctx.consume_network_location_groups()
+ elif args["key"].startswith("EnergyLink"):
ctx.current_energy_link_value = args["value"]
if ctx.ui:
ctx.ui.set_new_energy_link_value()
@@ -864,6 +1139,7 @@ async def console_loop(ctx: CommonContext):
def get_base_parser(description: typing.Optional[str] = None):
+ """Base argument parser to be reused for components subclassing off of CommonClient"""
import argparse
parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
@@ -873,10 +1149,36 @@ def get_base_parser(description: typing.Optional[str] = None):
return parser
-def run_as_textclient():
+def handle_url_arg(args: "argparse.Namespace",
+ parser: "typing.Optional[argparse.ArgumentParser]" = None) -> "argparse.Namespace":
+ """
+ Parse the url arg "archipelago://name:pass@host:port" from launcher into correct launch args for CommonClient
+ If alternate data is required the urlparse response is saved back to args.url if valid
+ """
+ if not args.url:
+ return args
+
+ url = urllib.parse.urlparse(args.url)
+ if url.scheme != "archipelago":
+ if not parser:
+ parser = get_base_parser()
+ parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
+ return args
+
+ args.url = url
+ args.connect = url.netloc
+ if url.username:
+ args.name = urllib.parse.unquote(url.username)
+ if url.password:
+ args.password = urllib.parse.unquote(url.password)
+
+ return args
+
+
+def run_as_textclient(*args):
class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry
- tags = {"AP", "TextOnly"}
+ tags = CommonContext.tags | {"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
@@ -885,7 +1187,7 @@ async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(TextContext, self).server_auth(password_requested)
await self.get_username()
- await self.send_connect()
+ await self.send_connect(game="")
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
@@ -912,21 +1214,17 @@ async def main(args):
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
parser.add_argument("url", nargs="?", help="Archipelago connection url")
- args = parser.parse_args()
+ args = parser.parse_args(args)
- if args.url:
- url = urllib.parse.urlparse(args.url)
- args.connect = url.netloc
- if url.username:
- args.name = urllib.parse.unquote(url.username)
- if url.password:
- args.password = urllib.parse.unquote(url.password)
+ args = handle_url_arg(args, parser=parser)
- colorama.init()
+ # use colorama to display colored text highlighting on windows
+ colorama.just_fix_windows_console()
asyncio.run(main(args))
colorama.deinit()
if __name__ == '__main__':
- run_as_textclient()
+ logging.getLogger().setLevel(logging.INFO) # force log-level to work around log level resetting to WARNING
+ run_as_textclient(*sys.argv[1:]) # default value for parse_args
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000000..363478988c96
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,100 @@
+# hadolint global ignore=SC1090,SC1091
+
+# Source
+FROM scratch AS release
+WORKDIR /release
+ADD https://github.com/Ijwu/Enemizer/releases/latest/download/ubuntu.16.04-x64.zip Enemizer.zip
+
+# Enemizer
+FROM alpine:3.21 AS enemizer
+ARG TARGETARCH
+WORKDIR /release
+COPY --from=release /release/Enemizer.zip .
+
+# No release for arm architecture. Skip.
+RUN if [ "$TARGETARCH" = "amd64" ]; then \
+ apk add unzip=6.0-r15 --no-cache && \
+ unzip -u Enemizer.zip -d EnemizerCLI && \
+ chmod -R 777 EnemizerCLI; \
+ else touch EnemizerCLI; fi
+
+# Cython builder stage
+FROM python:3.12 AS cython-builder
+
+WORKDIR /build
+
+# Copy and install requirements first (better caching)
+COPY requirements.txt WebHostLib/requirements.txt
+
+RUN pip install --no-cache-dir -r \
+ WebHostLib/requirements.txt \
+ "setuptools>=75,<81"
+
+COPY _speedups.pyx .
+COPY intset.h .
+
+RUN cythonize -b -i _speedups.pyx
+
+# Archipelago
+FROM python:3.12-slim-bookworm AS archipelago
+ARG TARGETARCH
+ENV VIRTUAL_ENV=/opt/venv
+ENV PYTHONUNBUFFERED=1
+WORKDIR /app
+
+# Install requirements
+# hadolint ignore=DL3008
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ git \
+ gcc=4:12.2.0-3 \
+ libc6-dev \
+ libtk8.6=8.6.13-2 \
+ g++=4:12.2.0-3 \
+ curl && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/*
+
+# Create and activate venv
+RUN python -m venv $VIRTUAL_ENV; \
+ . $VIRTUAL_ENV/bin/activate
+
+# Copy and install requirements first (better caching)
+COPY WebHostLib/requirements.txt WebHostLib/requirements.txt
+
+RUN pip install --no-cache-dir -r \
+ WebHostLib/requirements.txt \
+ gunicorn==23.0.0
+
+COPY . .
+
+COPY --from=cython-builder /build/*.so ./
+
+# Run ModuleUpdate
+RUN python ModuleUpdate.py -y
+
+# Purge unneeded packages
+RUN apt-get purge -y \
+ git \
+ gcc \
+ libc6-dev \
+ g++ && \
+ apt-get autoremove -y
+
+# Copy necessary components
+COPY --from=enemizer /release/EnemizerCLI /tmp/EnemizerCLI
+
+# No release for arm architecture. Skip.
+RUN if [ "$TARGETARCH" = "amd64" ]; then \
+ cp -r /tmp/EnemizerCLI EnemizerCLI; \
+ fi; \
+ rm -rf /tmp/EnemizerCLI
+
+# Define health check
+HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
+ CMD curl -f http://localhost:${PORT:-80} || exit 1
+
+# Ensure no runtime ModuleUpdate.
+ENV SKIP_REQUIREMENTS_UPDATE=true
+
+ENTRYPOINT [ "python", "WebHost.py" ]
diff --git a/FF1Client.py b/FF1Client.py
deleted file mode 100644
index b7c58e206123..000000000000
--- a/FF1Client.py
+++ /dev/null
@@ -1,267 +0,0 @@
-import asyncio
-import copy
-import json
-import time
-from asyncio import StreamReader, StreamWriter
-from typing import List
-
-
-import Utils
-from Utils import async_start
-from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
- get_base_parser
-
-SYSTEM_MESSAGE_ID = 0
-
-CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
-CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
-CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
-CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
-CONNECTION_CONNECTED_STATUS = "Connected"
-CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
-
-DISPLAY_MSGS = True
-
-
-class FF1CommandProcessor(ClientCommandProcessor):
- def __init__(self, ctx: CommonContext):
- super().__init__(ctx)
-
- def _cmd_nes(self):
- """Check NES Connection State"""
- if isinstance(self.ctx, FF1Context):
- logger.info(f"NES Status: {self.ctx.nes_status}")
-
- def _cmd_toggle_msgs(self):
- """Toggle displaying messages in EmuHawk"""
- global DISPLAY_MSGS
- DISPLAY_MSGS = not DISPLAY_MSGS
- logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
-
-
-class FF1Context(CommonContext):
- command_processor = FF1CommandProcessor
- game = 'Final Fantasy'
- items_handling = 0b111 # full remote
-
- def __init__(self, server_address, password):
- super().__init__(server_address, password)
- self.nes_streams: (StreamReader, StreamWriter) = None
- self.nes_sync_task = None
- self.messages = {}
- self.locations_array = None
- self.nes_status = CONNECTION_INITIAL_STATUS
- self.awaiting_rom = False
- self.display_msgs = True
-
- async def server_auth(self, password_requested: bool = False):
- if password_requested and not self.password:
- await super(FF1Context, self).server_auth(password_requested)
- if not self.auth:
- self.awaiting_rom = True
- logger.info('Awaiting connection to NES to get Player information')
- return
-
- await self.send_connect()
-
- def _set_message(self, msg: str, msg_id: int):
- if DISPLAY_MSGS:
- self.messages[time.time(), msg_id] = msg
-
- def on_package(self, cmd: str, args: dict):
- if cmd == 'Connected':
- async_start(parse_locations(self.locations_array, self, True))
- elif cmd == 'Print':
- msg = args['text']
- if ': !' not in msg:
- self._set_message(msg, SYSTEM_MESSAGE_ID)
-
- def on_print_json(self, args: dict):
- if self.ui:
- self.ui.print_json(copy.deepcopy(args["data"]))
- else:
- text = self.jsontotextparser(copy.deepcopy(args["data"]))
- logger.info(text)
- relevant = args.get("type", None) in {"Hint", "ItemSend"}
- if relevant:
- item = args["item"]
- # goes to this world
- if self.slot_concerns_self(args["receiving"]):
- relevant = True
- # found in this world
- elif self.slot_concerns_self(item.player):
- relevant = True
- # not related
- else:
- relevant = False
- if relevant:
- item = args["item"]
- msg = self.raw_text_parser(copy.deepcopy(args["data"]))
- self._set_message(msg, item.item)
-
- def run_gui(self):
- from kvui import GameManager
-
- class FF1Manager(GameManager):
- logging_pairs = [
- ("Client", "Archipelago")
- ]
- base_title = "Archipelago Final Fantasy 1 Client"
-
- self.ui = FF1Manager(self)
- self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
-
-
-def get_payload(ctx: FF1Context):
- current_time = time.time()
- return json.dumps(
- {
- "items": [item.item for item in ctx.items_received],
- "messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
- if key[0] > current_time - 10}
- }
- )
-
-
-async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
- if locations_array == ctx.locations_array and not force:
- return
- else:
- # print("New values")
- ctx.locations_array = locations_array
- locations_checked = []
- if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
- await ctx.send_msgs([
- {"cmd": "StatusUpdate",
- "status": 30}
- ])
- ctx.finished_game = True
- for location in ctx.missing_locations:
- # index will be - 0x100 or 0x200
- index = location
- if location < 0x200:
- # Location is a chest
- index -= 0x100
- flag = 0x04
- else:
- # Location is an NPC
- index -= 0x200
- flag = 0x02
-
- # print(f"Location: {ctx.location_names[location]}")
- # print(f"Index: {str(hex(index))}")
- # print(f"value: {locations_array[index] & flag != 0}")
- if locations_array[index] & flag != 0:
- locations_checked.append(location)
- if locations_checked:
- # print([ctx.location_names[location] for location in locations_checked])
- await ctx.send_msgs([
- {"cmd": "LocationChecks",
- "locations": locations_checked}
- ])
-
-
-async def nes_sync_task(ctx: FF1Context):
- logger.info("Starting nes connector. Use /nes for status information")
- while not ctx.exit_event.is_set():
- error_status = None
- if ctx.nes_streams:
- (reader, writer) = ctx.nes_streams
- msg = get_payload(ctx).encode()
- writer.write(msg)
- writer.write(b'\n')
- try:
- await asyncio.wait_for(writer.drain(), timeout=1.5)
- try:
- # Data will return a dict with up to two fields:
- # 1. A keepalive response of the Players Name (always)
- # 2. An array representing the memory values of the locations area (if in game)
- data = await asyncio.wait_for(reader.readline(), timeout=5)
- data_decoded = json.loads(data.decode())
- # print(data_decoded)
- if ctx.game is not None and 'locations' in data_decoded:
- # Not just a keep alive ping, parse
- async_start(parse_locations(data_decoded['locations'], ctx, False))
- if not ctx.auth:
- ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
- if ctx.auth == '':
- logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
- "the ROM using the same link but adding your slot name")
- if ctx.awaiting_rom:
- await ctx.server_auth(False)
- except asyncio.TimeoutError:
- logger.debug("Read Timed Out, Reconnecting")
- error_status = CONNECTION_TIMING_OUT_STATUS
- writer.close()
- ctx.nes_streams = None
- except ConnectionResetError as e:
- logger.debug("Read failed due to Connection Lost, Reconnecting")
- error_status = CONNECTION_RESET_STATUS
- writer.close()
- ctx.nes_streams = None
- except TimeoutError:
- logger.debug("Connection Timed Out, Reconnecting")
- error_status = CONNECTION_TIMING_OUT_STATUS
- writer.close()
- ctx.nes_streams = None
- except ConnectionResetError:
- logger.debug("Connection Lost, Reconnecting")
- error_status = CONNECTION_RESET_STATUS
- writer.close()
- ctx.nes_streams = None
- if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
- if not error_status:
- logger.info("Successfully Connected to NES")
- ctx.nes_status = CONNECTION_CONNECTED_STATUS
- else:
- ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
- elif error_status:
- ctx.nes_status = error_status
- logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
- else:
- try:
- logger.debug("Attempting to connect to NES")
- ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
- ctx.nes_status = CONNECTION_TENTATIVE_STATUS
- except TimeoutError:
- logger.debug("Connection Timed Out, Trying Again")
- ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
- continue
- except ConnectionRefusedError:
- logger.debug("Connection Refused, Trying Again")
- ctx.nes_status = CONNECTION_REFUSED_STATUS
- continue
-
-
-if __name__ == '__main__':
- # Text Mode to use !hint and such with games that have no text entry
- Utils.init_logging("FF1Client")
-
- options = Utils.get_options()
- DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
-
- async def main(args):
- ctx = FF1Context(args.connect, args.password)
- ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
- if gui_enabled:
- ctx.run_gui()
- ctx.run_cli()
- ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
-
- await ctx.exit_event.wait()
- ctx.server_address = None
-
- await ctx.shutdown()
-
- if ctx.nes_sync_task:
- await ctx.nes_sync_task
-
-
- import colorama
-
- parser = get_base_parser()
- args = parser.parse_args()
- colorama.init()
-
- asyncio.run(main(args))
- colorama.deinit()
diff --git a/FactorioClient.py b/FactorioClient.py
deleted file mode 100644
index 070ca503269f..000000000000
--- a/FactorioClient.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from __future__ import annotations
-
-import ModuleUpdate
-ModuleUpdate.update()
-
-from worlds.factorio.Client import check_stdin, launch
-import Utils
-
-if __name__ == "__main__":
- Utils.init_logging("FactorioClient", exception_logger="Client")
- check_stdin()
- launch()
diff --git a/Fill.py b/Fill.py
index 3e0342f42cd3..48ed7253d9d1 100644
--- a/Fill.py
+++ b/Fill.py
@@ -4,61 +4,89 @@
import typing
from collections import Counter, deque
-from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
+from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
+from Options import Accessibility
+
from worlds.AutoWorld import call_all
from worlds.generic.Rules import add_item_rule
class FillError(RuntimeError):
- pass
+ def __init__(self, *args: typing.Union[str, typing.Any], **kwargs) -> None:
+ if "multiworld" in kwargs and isinstance(args[0], str):
+ placements = (args[0] + f"\nAll Placements:\n" +
+ f"{[(loc, loc.item) for loc in kwargs['multiworld'].get_filled_locations()]}")
+ args = (placements, *args[1:])
+ super().__init__(*args)
+
+
+def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
+ logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.")
-def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState:
+def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple(),
+ locations: typing.Optional[typing.List[Location]] = None) -> CollectionState:
new_state = base_state.copy()
for item in itempool:
new_state.collect(item, True)
- new_state.sweep_for_events()
+ new_state.sweep_for_advancements(locations=locations)
return new_state
-def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
+def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
- allow_partial: bool = False, allow_excluded: bool = False) -> None:
+ allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True,
+ name: str = "Unknown") -> None:
"""
- :param world: Multiworld to be filled.
+ :param multiworld: Multiworld to be filled.
:param base_state: State assumed before fill.
- :param locations: Locations to be filled with item_pool
- :param item_pool: Items to fill into the locations
+ :param locations: Locations to be filled with item_pool, gets mutated by removing locations that get filled.
+ :param item_pool: Items to fill into the locations, gets mutated by removing items that get placed.
:param single_player_placement: if true, can speed up placement if everything belongs to a single player
:param lock: locations are set to locked as they are filled
:param swap: if true, swaps of already place items are done in the event of a dead end
:param on_place: callback that is called when a placement happens
:param allow_partial: only place what is possible. Remaining items will be in the item_pool list.
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
+ :param name: name of this fill step for progress logging purposes
"""
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
cleanup_required = False
-
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
for item in item_pool:
reachable_items.setdefault(item.player, deque()).append(item)
+ # for progress logging
+ total = min(len(item_pool), len(locations))
+ placed = 0
+
while any(reachable_items.values()) and locations:
- # grab one item per player
- items_to_place = [items.pop()
- for items in reachable_items.values() if items]
+ if one_item_per_player:
+ # grab one item per player
+ items_to_place = [items.pop()
+ for items in reachable_items.values() if items]
+ else:
+ next_player = multiworld.random.choice([player for player, items in reachable_items.items() if items])
+ items_to_place = []
+ if item_pool:
+ items_to_place.append(reachable_items[next_player].pop())
+
for item in items_to_place:
- for p, pool_item in enumerate(item_pool):
+ # The items added into `reachable_items` are placed starting from the end of each deque in
+ # `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`.
+ for p, pool_item in enumerate(reversed(item_pool), start=1):
if pool_item is item:
- item_pool.pop(p)
+ del item_pool[-p]
break
+
maximum_exploration_state = sweep_from_pool(
- base_state, item_pool + unplaced_items)
+ base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
+ if single_player_placement else None)
- has_beaten_game = world.has_beaten_game(maximum_exploration_state)
+ has_beaten_game = multiworld.has_beaten_game(maximum_exploration_state)
while items_to_place:
# if we have run out of locations to fill,break out of this loop
@@ -70,9 +98,9 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
spot_to_fill: typing.Optional[Location] = None
# if minimal accessibility, only check whether location is reachable if game not beatable
- if world.accessibility[item_to_place.player] == 'minimal':
- perform_access_check = not world.has_beaten_game(maximum_exploration_state,
- item_to_place.player) \
+ if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
+ perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
+ item_to_place.player) \
if single_player_placement else not has_beaten_game
else:
perform_access_check = True
@@ -88,12 +116,23 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
else:
# we filled all reachable spots.
if swap:
+ # Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
+ # swap state, instead of sweeping from `base_state` each time.
+ previous_safe_swap_state_cache: typing.Deque[CollectionState] = deque()
+ # Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
+ # single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
+ max_swap_base_state_cache_length = 3
+
# try swapping this item with previously placed items in a safe way then in an unsafe way
swap_attempts = ((i, location, unsafe)
for unsafe in (False, True)
for i, location in enumerate(placements))
for (i, location, unsafe) in swap_attempts:
placed_item = location.item
+ if item_to_place == placed_item:
+ # The number of allowed swaps is limited, so do not allow a swap of an item with a copy of
+ # itself.
+ continue
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]
@@ -102,38 +141,50 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
location.item = None
placed_item.location = None
- swap_state = sweep_from_pool(base_state, [placed_item] if unsafe else [])
+
+ for previous_safe_swap_state in previous_safe_swap_state_cache:
+ # If a state has already checked the location of the swap, then it cannot be used.
+ if location not in previous_safe_swap_state.advancements:
+ # Previous swap states will have collected all items in `item_pool`, so the new
+ # `swap_state` can skip having to collect them again.
+ # Previous swap states will also have already checked many locations, making the sweep
+ # faster.
+ swap_state = sweep_from_pool(previous_safe_swap_state, (placed_item,) if unsafe else (),
+ multiworld.get_filled_locations(item.player)
+ if single_player_placement else None)
+ break
+ else:
+ # No previous swap_state was usable as a base state to sweep from, so create a new one.
+ swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
+ multiworld.get_filled_locations(item.player)
+ if single_player_placement else None)
+ # Unsafe states should not be added to the cache because they have collected `placed_item`.
+ if not unsafe:
+ if len(previous_safe_swap_state_cache) >= max_swap_base_state_cache_length:
+ # Remove the oldest cached state.
+ previous_safe_swap_state_cache.pop()
+ # Add the new state to the start of the cache.
+ previous_safe_swap_state_cache.appendleft(swap_state)
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
# to clean that up later, so there is a chance generation fails.
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
+ # Add this item to the existing placement, and
+ # add the old item to the back of the queue
+ spot_to_fill = placements.pop(i)
- # Verify placing this item won't reduce available locations, which would be a useless swap.
- prev_state = swap_state.copy()
- prev_loc_count = len(
- world.get_reachable_locations(prev_state))
+ swap_count += 1
+ swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
- swap_state.collect(item_to_place, True)
- new_loc_count = len(
- world.get_reachable_locations(swap_state))
+ reachable_items[placed_item.player].appendleft(
+ placed_item)
+ item_pool.append(placed_item)
- if new_loc_count >= prev_loc_count:
- # Add this item to the existing placement, and
- # add the old item to the back of the queue
- spot_to_fill = placements.pop(i)
+ # cleanup at the end to hopefully get better errors
+ cleanup_required = True
- swap_count += 1
- swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
-
- reachable_items[placed_item.player].appendleft(
- placed_item)
- item_pool.append(placed_item)
-
- # cleanup at the end to hopefully get better errors
- cleanup_required = True
-
- break
+ break
# Item can't be placed here, restore original item
location.item = placed_item
@@ -146,18 +197,25 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
else:
unplaced_items.append(item_to_place)
continue
- world.push_item(spot_to_fill, item_to_place, False)
+ multiworld.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked = lock
placements.append(spot_to_fill)
- spot_to_fill.event = item_to_place.advancement
+ placed += 1
+ if not placed % 1000:
+ _log_fill_progress(name, placed, total)
if on_place:
on_place(spot_to_fill)
+ if total > 1000:
+ _log_fill_progress(name, placed, total)
+
if cleanup_required:
# validate all placements and remove invalid ones
- state = sweep_from_pool(base_state, [])
+ state = sweep_from_pool(
+ base_state, [], multiworld.get_filled_locations(item.player)
+ if single_player_placement else None)
for placement in placements:
- if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state):
+ if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state):
placement.item.location = None
unplaced_items.append(placement.item)
placement.item = None
@@ -172,7 +230,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
if excluded_locations:
for location in excluded_locations:
location.progress_type = location.progress_type.DEFAULT
- fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
+ fill_restrictive(multiworld, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
swap, on_place, allow_partial, False)
for location in excluded_locations:
if not location.item:
@@ -180,28 +238,50 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
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():
+ if multiworld.can_beat_game():
logging.warning(
- f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})')
+ f"Not all items placed. Game beatable anyway.\nCould not place:\n"
+ f"{', '.join(str(item) for item in unplaced_items)}")
else:
- raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
- f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
+ raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
+ f"Unplaced items:\n"
+ f"{', '.join(str(item) for item in unplaced_items)}\n"
+ f"Unfilled locations:\n"
+ f"{', '.join(str(location) for location in locations)}\n"
+ f"Already placed {len(placements)}:\n"
+ f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
item_pool.extend(unplaced_items)
-def remaining_fill(world: MultiWorld,
+def remaining_fill(multiworld: MultiWorld,
locations: typing.List[Location],
- itempool: typing.List[Item]) -> None:
+ itempool: typing.List[Item],
+ name: str = "Remaining",
+ move_unplaceable_to_start_inventory: bool = False,
+ check_location_can_fill: bool = False) -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
+ total = min(len(itempool), len(locations))
+ placed = 0
+
+ # Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
+ if check_location_can_fill:
+ state = CollectionState(multiworld)
+
+ def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
+ return location_to_fill.can_fill(state, item_to_fill, check_access=False)
+ else:
+ def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
+ return location_to_fill.item_rule(item_to_fill)
+
while locations and itempool:
item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None
for i, location in enumerate(locations):
- if location.item_rule(item_to_place):
+ if location_can_fill_item(location, item_to_place):
# popping by index is faster than removing by content,
spot_to_fill = locations.pop(i)
# skipping a scan for the element
@@ -222,7 +302,7 @@ def remaining_fill(world: MultiWorld,
location.item = None
placed_item.location = None
- if location.item_rule(item_to_place):
+ if location_can_fill_item(location, item_to_place):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
@@ -243,73 +323,98 @@ def remaining_fill(world: MultiWorld,
unplaced_items.append(item_to_place)
continue
- world.push_item(spot_to_fill, item_to_place, False)
+ multiworld.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill)
+ placed += 1
+ if not placed % 1000:
+ _log_fill_progress(name, placed, total)
+
+ if total > 1000:
+ _log_fill_progress(name, placed, total)
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
- raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
- f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
+ if move_unplaceable_to_start_inventory:
+ last_batch = []
+ for item in unplaced_items:
+ logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
+ multiworld.push_precollected(item)
+ last_batch.append(multiworld.worlds[item.player].create_filler())
+ remaining_fill(multiworld, locations, unplaced_items, name + " Start Inventory Retry")
+ else:
+ raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
+ f"Unplaced items:\n"
+ f"{', '.join(str(item) for item in unplaced_items)}\n"
+ f"Unfilled locations:\n"
+ f"{', '.join(str(location) for location in locations)}\n"
+ f"Already placed {len(placements)}:\n"
+ f"{', '.join(str(place) for place in placements)}", multiworld=multiworld)
itempool.extend(unplaced_items)
-def fast_fill(world: MultiWorld,
+def fast_fill(multiworld: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
- world.push_item(location, item, False)
+ multiworld.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
-def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
+def accessibility_corrections(multiworld: MultiWorld,
+ state: CollectionState,
+ locations: list[Location],
+ pool: list[Item] | None = None) -> None:
+ if pool is None:
+ pool = []
maximum_exploration_state = sweep_from_pool(state, pool)
- minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
- unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
+ minimal_players = {player for player in multiworld.player_ids if
+ multiworld.worlds[player].options.accessibility == "minimal"}
+ unreachable_locations = [location for location in multiworld.get_locations() if
+ location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not
location.locked and location.item.player not in minimal_players):
pool.append(location.item)
- state.remove(location.item)
location.item = None
- location.event = False
- if location in state.events:
- state.events.remove(location)
+ if location in state.advancements:
+ state.advancements.remove(location)
+ state.remove(location.item)
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
- fill_restrictive(world, state, locations, pool)
+ fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections")
-def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
+def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations):
maximum_exploration_state = sweep_from_pool(state)
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
- return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
+ return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
-def distribute_early_items(world: MultiWorld,
+def distribute_early_items(multiworld: 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], typing.List[int]] = {}
- for player in world.player_ids:
- items = itertools.chain(world.early_items[player], world.local_early_items[player])
+ for player in multiworld.player_ids:
+ items = itertools.chain(multiworld.early_items[player], multiworld.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] = [multiworld.early_items[player].get(item, 0),
+ multiworld.local_early_items[player].get(item, 0)]
if early_items_count:
early_locations: typing.List[Location] = []
early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set()
- base_state = world.state.copy()
- base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
+ base_state = multiworld.state.copy()
+ base_state.sweep_for_advancements(locations=(loc for loc in multiworld.get_filled_locations() if loc.address is None))
for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY:
@@ -321,8 +426,8 @@ def distribute_early_items(world: MultiWorld,
early_prog_items: typing.List[Item] = []
early_rest_items: typing.List[Item] = []
- early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
- early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
+ early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
+ early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in multiworld.player_ids}
item_indexes_to_remove: typing.Set[int] = set()
for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count:
@@ -346,27 +451,29 @@ def distribute_early_items(world: MultiWorld,
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:
+ for player in multiworld.player_ids:
player_local = early_local_rest_items[player]
- fill_restrictive(world, base_state,
+ fill_restrictive(multiworld, base_state,
[loc for loc in early_locations if loc.player == player],
- player_local, lock=True, allow_partial=True)
+ player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}")
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, allow_partial=True)
+ fill_restrictive(multiworld, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
+ name="Early Items")
early_locations += early_priority_locations
- for player in world.player_ids:
+ for player in multiworld.player_ids:
player_local = early_local_prog_items[player]
- fill_restrictive(world, base_state,
+ fill_restrictive(multiworld, base_state,
[loc for loc in early_locations if loc.player == player],
- player_local, lock=True, allow_partial=True)
+ player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}")
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, allow_partial=True)
+ fill_restrictive(multiworld, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
+ name="Early Progression")
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 "
@@ -374,18 +481,25 @@ def distribute_early_items(world: MultiWorld,
itempool += unplaced_early_items
fill_locations.extend(early_locations)
- world.random.shuffle(fill_locations)
+ multiworld.random.shuffle(fill_locations)
return fill_locations, itempool
-def distribute_items_restrictive(world: MultiWorld) -> None:
- fill_locations = sorted(world.get_unfilled_locations())
- world.random.shuffle(fill_locations)
+def distribute_items_restrictive(multiworld: MultiWorld,
+ panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
+ assert all(item.location is None for item in multiworld.itempool), (
+ "At the start of distribute_items_restrictive, "
+ "there are items in the multiworld itempool that are already placed on locations:\n"
+ f"{[(item.location, item) for item in multiworld.itempool if item.location is not None]}"
+ )
+
+ fill_locations = sorted(multiworld.get_unfilled_locations())
+ multiworld.random.shuffle(fill_locations)
# get items to distribute
- itempool = sorted(world.itempool)
- world.random.shuffle(itempool)
+ itempool = sorted(multiworld.itempool)
+ multiworld.random.shuffle(itempool)
- fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
+ fill_locations, itempool = distribute_early_items(multiworld, fill_locations, itempool)
progitempool: typing.List[Item] = []
usefulitempool: typing.List[Item] = []
@@ -399,7 +513,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
else:
filleritempool.append(item)
- call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
+ call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
loc_type: [] for loc_type in LocationProgressType}
@@ -418,76 +532,169 @@ def mark_for_locking(location: Location):
nonlocal lock_later
lock_later.append(location)
+ single_player = multiworld.players == 1 and not multiworld.groups
+
if prioritylocations:
+ regular_progression = []
+ deprioritized_progression = []
+ for item in progitempool:
+ if item.deprioritized:
+ deprioritized_progression.append(item)
+ else:
+ regular_progression.append(item)
+
# "priority fill"
- fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
- accessibility_corrections(world, world.state, prioritylocations, progitempool)
+ # try without deprioritized items in the mix at all. This means they need to be collected into state first.
+ priority_fill_state = sweep_from_pool(multiworld.state, deprioritized_progression)
+ fill_restrictive(multiworld, priority_fill_state, prioritylocations, regular_progression,
+ single_player_placement=single_player, swap=False, on_place=mark_for_locking,
+ name="Priority", one_item_per_player=True, allow_partial=True)
+
+ if prioritylocations and regular_progression:
+ # retry with one_item_per_player off because some priority fills can fail to fill with that optimization
+ # deprioritized items are still not in the mix, so they need to be collected into state first.
+ # allow_partial should only be set if there is deprioritized progression to fall back on.
+ priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression)
+ fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
+ single_player_placement=single_player, swap=False, on_place=mark_for_locking,
+ name="Priority Retry", one_item_per_player=False,
+ allow_partial=bool(deprioritized_progression))
+
+ if prioritylocations and deprioritized_progression:
+ # There are no more regular progression items that can be placed on any priority locations.
+ # We'd still prefer to place deprioritized progression items on priority locations over filler items.
+ # Since we're leaving out the remaining regular progression now, we need to collect it into state first.
+ priority_retry_2_state = sweep_from_pool(multiworld.state, regular_progression)
+ fill_restrictive(multiworld, priority_retry_2_state, prioritylocations, deprioritized_progression,
+ single_player_placement=single_player, swap=False, on_place=mark_for_locking,
+ name="Priority Retry 2", one_item_per_player=True, allow_partial=True)
+
+ if prioritylocations and deprioritized_progression:
+ # retry with deprioritized items AND without one_item_per_player optimisation
+ # Since we're leaving out the remaining regular progression now, we need to collect it into state first.
+ priority_retry_3_state = sweep_from_pool(multiworld.state, regular_progression)
+ fill_restrictive(multiworld, priority_retry_3_state, prioritylocations, deprioritized_progression,
+ single_player_placement=single_player, swap=False, on_place=mark_for_locking,
+ name="Priority Retry 3", one_item_per_player=False)
+
+ # restore original order of progitempool
+ progitempool[:] = [item for item in progitempool if not item.location]
+ accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations
if progitempool:
- # "progression fill"
- fill_restrictive(world, world.state, defaultlocations, progitempool)
+ # "advancement/progression fill"
+ maximum_exploration_state = sweep_from_pool(multiworld.state)
+ if panic_method == "swap":
+ fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True,
+ name="Progression", single_player_placement=single_player)
+ elif panic_method == "raise":
+ fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
+ name="Progression", single_player_placement=single_player)
+ elif panic_method == "start_inventory":
+ fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
+ allow_partial=True, name="Progression", single_player_placement=single_player)
+ if progitempool:
+ for item in progitempool:
+ logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
+ multiworld.push_precollected(item)
+ filleritempool.append(multiworld.worlds[item.player].create_filler())
+ logging.warning(f"{len(progitempool)} items moved to start inventory,"
+ f" due to failure in Progression fill step.")
+ progitempool[:] = []
+
+ else:
+ raise ValueError(f"Generator Panic Method {panic_method} not recognized.")
if progitempool:
raise FillError(
- f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
- accessibility_corrections(world, world.state, defaultlocations)
+ f"Not enough locations for progression items. "
+ f"There are {len(progitempool)} more progression items than there are available locations.\n"
+ f"Unfilled locations:\n{multiworld.get_unfilled_locations()}.",
+ multiworld=multiworld,
+ )
+ accessibility_corrections(multiworld, multiworld.state, defaultlocations)
for location in lock_later:
if location.item:
location.locked = True
del mark_for_locking, lock_later
- inaccessible_location_rules(world, world.state, defaultlocations)
+ inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
+
+ remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded",
+ move_unplaceable_to_start_inventory=panic_method=="start_inventory")
- remaining_fill(world, excludedlocations, filleritempool)
if excludedlocations:
raise FillError(
- f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
+ f"Not enough filler items for excluded locations. "
+ f"There are {len(excludedlocations)} more excluded locations than excludable items.",
+ multiworld=multiworld,
+ )
- restitempool = usefulitempool + filleritempool
+ restitempool = filleritempool + usefulitempool
- remaining_fill(world, defaultlocations, restitempool)
+ remaining_fill(multiworld, defaultlocations, restitempool,
+ move_unplaceable_to_start_inventory=panic_method=="start_inventory")
unplaced = restitempool
unfilled = defaultlocations
if unplaced or unfilled:
logging.warning(
- f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
- items_counter = Counter(location.item.player for location in world.get_locations() if location.item)
- locations_counter = Counter(location.player for location in world.get_locations())
+ f"Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}")
+ items_counter = Counter(location.item.player for location in multiworld.get_filled_locations())
+ locations_counter = Counter(location.player for location in multiworld.get_locations())
items_counter.update(item.player for item in unplaced)
- locations_counter.update(location.player for location in unfilled)
print_data = {"items": items_counter, "locations": locations_counter}
- logging.info(f'Per-Player counts: {print_data})')
+ logging.info(f"Per-Player counts: {print_data})")
+
+ more_locations = locations_counter - items_counter
+ more_items = items_counter - locations_counter
+ for player in multiworld.player_ids:
+ if more_locations[player]:
+ logging.error(
+ f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
+ elif more_items[player]:
+ logging.warning(
+ f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
+ if unfilled:
+ raise FillError(
+ f"Unable to fill all locations.\n" +
+ f"Unfilled locations({len(unfilled)}): {unfilled}"
+ )
+ else:
+ logging.warning(
+ f"Unable to place all items.\n" +
+ f"Unplaced items({len(unplaced)}): {unplaced}"
+ )
-def flood_items(world: MultiWorld) -> None:
+def flood_items(multiworld: MultiWorld) -> None:
# get items to distribute
- world.random.shuffle(world.itempool)
- itempool = world.itempool
+ multiworld.random.shuffle(multiworld.itempool)
+ itempool = multiworld.itempool
progress_done = False
# sweep once to pick up preplaced items
- world.state.sweep_for_events()
+ multiworld.state.sweep_for_advancements()
- # fill world from top of itempool while we can
+ # fill multiworld from top of itempool while we can
while not progress_done:
- location_list = world.get_unfilled_locations()
- world.random.shuffle(location_list)
+ location_list = multiworld.get_unfilled_locations()
+ multiworld.random.shuffle(location_list)
spot_to_fill = None
for location in location_list:
- if location.can_fill(world.state, itempool[0]):
+ if location.can_fill(multiworld.state, itempool[0]):
spot_to_fill = location
break
if spot_to_fill:
item = itempool.pop(0)
- world.push_item(spot_to_fill, item, True)
+ multiworld.push_item(spot_to_fill, item, True)
continue
# ran out of spots, check if we need to step in and correct things
- if len(world.get_reachable_locations()) == len(world.get_locations()):
+ if len(multiworld.get_reachable_locations()) == len(multiworld.get_locations()):
progress_done = True
continue
@@ -497,7 +704,7 @@ def flood_items(world: MultiWorld) -> None:
for item in itempool:
if item.advancement:
candidate_item_to_place = item
- if world.unlocks_new_location(item):
+ if multiworld.unlocks_new_location(item):
item_to_place = item
break
@@ -507,23 +714,23 @@ def flood_items(world: MultiWorld) -> None:
if candidate_item_to_place is not None:
item_to_place = candidate_item_to_place
else:
- raise FillError('No more progress items left to place.')
+ raise FillError('No more progress items left to place.', multiworld=multiworld)
# find item to replace with progress item
- location_list = world.get_reachable_locations()
- world.random.shuffle(location_list)
+ location_list = multiworld.get_reachable_locations()
+ multiworld.random.shuffle(location_list)
for location in location_list:
if location.item is not None and not location.item.advancement:
# safe to replace
replace_item = location.item
replace_item.location = None
itempool.append(replace_item)
- world.push_item(location, item_to_place, True)
+ multiworld.push_item(location, item_to_place, True)
itempool.remove(item_to_place)
break
-def balance_multiworld_progression(world: MultiWorld) -> None:
+def balance_multiworld_progression(multiworld: MultiWorld) -> None:
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
# Overall progression balancing algorithm:
# Gather up all locations in a sphere.
@@ -531,28 +738,28 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = {
- player: world.progression_balancing[player] / 100
- for player in world.player_ids
- if world.progression_balancing[player] > 0
+ player: multiworld.worlds[player].options.progression_balancing / 100
+ for player in multiworld.player_ids
+ if multiworld.worlds[player].options.progression_balancing > 0
}
if not balanceable_players:
- logging.info('Skipping multiworld progression balancing.')
+ logging.info("Skipping multiworld progression balancing.")
else:
- logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
+ logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
logging.debug(balanceable_players)
- state: CollectionState = CollectionState(world)
+ state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set()
- unchecked_locations: typing.Set[Location] = set(world.get_locations())
+ unchecked_locations: typing.Set[Location] = set(multiworld.get_locations())
total_locations_count: typing.Counter[int] = Counter(
location.player
- for location in world.get_locations()
+ for location in multiworld.get_locations()
if not location.locked
)
reachable_locations_count: typing.Dict[int, int] = {
player: 0
- for player in world.player_ids
- if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
+ for player in multiworld.player_ids
+ if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0
}
balanceable_players = {
player: balanceable_players[player]
@@ -564,7 +771,6 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
def get_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]:
- sphere_state.sweep_for_events(key_only=True, locations=locations)
return {loc for loc in locations if sphere_state.can_reach(loc)}
def item_percentage(player: int, num: int) -> float:
@@ -616,7 +822,7 @@ def item_percentage(player: int, num: int) -> float:
while True:
# Check locations in the current sphere and gather progression items to swap earlier
for location in balancing_sphere:
- if location.event:
+ if location.advancement:
balancing_state.collect(location.item, True, location)
player = location.item.player
# only replace items that end up in another player's world
@@ -631,13 +837,13 @@ def item_percentage(player: int, num: int) -> float:
balancing_unchecked_locations.remove(location)
if not location.locked:
balancing_reachables[location.player] += 1
- if world.has_beaten_game(balancing_state) or all(
+ if multiworld.has_beaten_game(balancing_state) or all(
item_percentage(player, reachables) >= threshold_percentages[player]
for player, reachables in balancing_reachables.items()
if player in threshold_percentages):
break
elif not balancing_sphere:
- raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
+ raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
# Gather a set of locations which we can swap items into
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
for l in unchecked_locations:
@@ -648,20 +854,20 @@ def item_percentage(player: int, num: int) -> float:
locations_to_test = unlocked_locations[player]
items_to_test = list(candidate_items[player])
items_to_test.sort()
- world.random.shuffle(items_to_test)
+ multiworld.random.shuffle(items_to_test)
while items_to_test:
testing = items_to_test.pop()
reducing_state = state.copy()
for location in itertools.chain((
- l for l in items_to_replace
- if l.item.player == player
+ l for l in items_to_replace
+ if l.item.player == player
), items_to_test):
reducing_state.collect(location.item, True, location)
- reducing_state.sweep_for_events(locations=locations_to_test)
+ reducing_state.sweep_for_advancements(locations=locations_to_test)
- if world.has_beaten_game(balancing_state):
- if not world.has_beaten_game(reducing_state):
+ if multiworld.has_beaten_game(balancing_state):
+ if not multiworld.has_beaten_game(reducing_state):
items_to_replace.append(testing)
else:
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
@@ -669,33 +875,32 @@ def item_percentage(player: int, num: int) -> float:
if p < threshold_percentages[player]:
items_to_replace.append(testing)
- replaced_items = False
+ old_moved_item_count = moved_item_count
# sort then shuffle to maintain deterministic behaviour,
# while allowing use of set for better algorithm growth behaviour elsewhere
- replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked)
- world.random.shuffle(replacement_locations)
+ replacement_locations = sorted(l for l in checked_locations if not l.advancement and not l.locked)
+ multiworld.random.shuffle(replacement_locations)
items_to_replace.sort()
- world.random.shuffle(items_to_replace)
+ multiworld.random.shuffle(items_to_replace)
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
while replacement_locations and items_to_replace:
old_location = items_to_replace.pop()
- for new_location in replacement_locations:
+ for i, new_location in enumerate(replacement_locations):
if new_location.can_fill(state, old_location.item, False) and \
old_location.can_fill(state, new_location.item, False):
- replacement_locations.remove(new_location)
+ replacement_locations.pop(i)
swap_location_item(old_location, new_location)
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
f"displacing {old_location.item} into {old_location}")
moved_item_count += 1
state.collect(new_location.item, True, new_location)
- replaced_items = True
break
else:
logging.warning(f"Could not Progression Balance {old_location.item}")
- if replaced_items:
+ if old_moved_item_count < moved_item_count:
logging.debug(f"Moved {moved_item_count} items so far\n")
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
for location in get_sphere_locations(state, unlocked):
@@ -705,11 +910,11 @@ def item_percentage(player: int, num: int) -> float:
sphere_locations.add(location)
for location in sphere_locations:
- if location.event:
+ if location.advancement:
state.collect(location.item, True, location)
checked_locations |= sphere_locations
- if world.has_beaten_game(state):
+ if multiworld.has_beaten_game(state):
break
elif not sphere_locations:
logging.warning("Progression Balancing ran out of paths.")
@@ -726,214 +931,235 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
location_2.item, location_1.item = location_1.item, location_2.item
location_1.item.location = location_1
location_2.item.location = location_2
- location_1.event, location_2.event = location_2.event, location_1.event
-def distribute_planned(world: MultiWorld) -> None:
- def warn(warning: str, force: typing.Union[bool, str]) -> None:
- if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
- logging.warning(f'{warning}')
+def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]:
+ def warn(warning: str, force: bool | str) -> None:
+ if isinstance(force, bool):
+ logging.warning(f"{warning}")
else:
- logging.debug(f'{warning}')
+ logging.debug(f"{warning}")
- def failed(warning: str, force: typing.Union[bool, str]) -> None:
- if force in [True, 'fail', 'failure']:
+ def failed(warning: str, force: bool | str) -> None:
+ if force is True:
raise Exception(warning)
else:
warn(warning, force)
- swept_state = world.state.copy()
- swept_state.sweep_for_events()
- reachable = frozenset(world.get_reachable_locations(swept_state))
- early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
- non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
- for loc in world.get_unfilled_locations():
- if loc in reachable:
- early_locations[loc.player].append(loc.name)
- else: # not reachable with swept state
- non_early_locations[loc.player].append(loc.name)
-
- # TODO: remove. Preferably by implementing key drop
- from worlds.alttp.Regions import key_drop_data
- world_name_lookup = world.world_name_lookup
+ world_name_lookup = multiworld.world_name_lookup
- block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
- plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
- player_ids = set(world.player_ids)
+ plando_blocks: dict[int, list[PlandoItemBlock]] = dict()
+ player_ids: set[int] = set(multiworld.player_ids)
for player in player_ids:
- for block in world.plando_items[player]:
- block['player'] = player
- if 'force' not in block:
- block['force'] = 'silent'
- if 'from_pool' not in block:
- block['from_pool'] = True
- if 'world' not in block:
- target_world = False
- else:
- target_world = block['world']
-
- if target_world is False or world.players == 1: # target own world
- worlds: typing.Set[int] = {player}
+ plando_blocks[player] = []
+ for block in multiworld.worlds[player].options.plando_items:
+ new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
+ target_world = block.world
+ if target_world is False or multiworld.players == 1: # target own world
+ worlds: set[int] = {player}
elif target_world is True: # target any worlds besides own
- worlds = set(world.player_ids) - {player}
+ worlds = set(multiworld.player_ids) - {player}
elif target_world is None: # target all worlds
- worlds = set(world.player_ids)
+ worlds = set(multiworld.player_ids)
elif type(target_world) == list: # list of target worlds
worlds = set()
for listed_world in target_world:
if listed_world not in world_name_lookup:
- failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
- block['force'])
+ failed(f"Cannot place item to {listed_world}'s world as that world does not exist.",
+ block.force)
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
- if target_world not in range(1, world.players + 1):
+ if target_world not in range(1, multiworld.players + 1):
failed(
- f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
- block['force'])
+ f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
+ block.force)
continue
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
- block['force'])
+ block.force)
continue
worlds = {world_name_lookup[target_world]}
- block['world'] = worlds
-
- items: block_value = []
- if "items" in block:
- items = block["items"]
- if 'count' not in block:
- block['count'] = False
- elif "item" in block:
- items = block["item"]
- if 'count' not in block:
- block['count'] = 1
- else:
- failed("You must specify at least one item to place items with plando.", block['force'])
- continue
+ new_block.worlds = worlds
+
+ items: list[str] | dict[str, typing.Any] = block.items
if isinstance(items, dict):
- item_list: typing.List[str] = []
+ item_list: list[str] = []
for key, value in items.items():
if value is True:
- value = world.itempool.count(world.worlds[player].create_item(key))
+ value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
item_list += [key] * value
items = item_list
- if isinstance(items, str):
- items = [items]
- block['items'] = items
-
- locations: block_value = []
- if 'location' in block:
- locations = block['location'] # just allow 'location' to keep old yamls compatible
- elif 'locations' in block:
- locations = block['locations']
+ new_block.items = items
+
+ locations: list[str] = block.locations
if isinstance(locations, str):
locations = [locations]
- if isinstance(locations, dict):
- location_list = []
- for key, value in locations.items():
- location_list += [key] * value
- locations = location_list
+ resolved_locations: list[Location] = []
+ for target_player in worlds:
+ locations_from_groups: list[str] = []
+ world_locations = multiworld.get_unfilled_locations(target_player)
+ for group in multiworld.worlds[target_player].location_name_groups:
+ if group in locations:
+ locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
+ resolved_locations.extend(location for location in world_locations
+ if location.name in [*locations, *locations_from_groups])
+ new_block.locations = sorted(dict.fromkeys(locations))
+ new_block.resolved_locations = sorted(set(resolved_locations))
+
+ count = block.count
+ if not count:
+ count = (min(len(new_block.items), len(new_block.resolved_locations))
+ if new_block.resolved_locations else len(new_block.items))
+ if isinstance(count, int):
+ count = {"min": count, "max": count}
+ if "min" not in count:
+ count["min"] = 0
+ if "max" not in count:
+ count["max"] = (min(len(new_block.items), len(new_block.resolved_locations))
+ if new_block.resolved_locations else len(new_block.items))
+
+
+ new_block.count = count
+ plando_blocks[player].append(new_block)
+
+ return plando_blocks
+
+
+def resolve_early_locations_for_planned(multiworld: MultiWorld):
+ def warn(warning: str, force: bool | str) -> None:
+ if isinstance(force, bool):
+ logging.warning(f"{warning}")
+ else:
+ logging.debug(f"{warning}")
+
+ def failed(warning: str, force: bool | str) -> None:
+ if force is True:
+ raise Exception(warning)
+ else:
+ warn(warning, force)
+ swept_state = multiworld.state.copy()
+ swept_state.sweep_for_advancements()
+ reachable = frozenset(multiworld.get_reachable_locations(swept_state))
+ early_locations: dict[int, list[Location]] = collections.defaultdict(list)
+ non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
+ for loc in multiworld.get_unfilled_locations():
+ if loc in reachable:
+ early_locations[loc.player].append(loc)
+ else: # not reachable with swept state
+ non_early_locations[loc.player].append(loc)
+
+ for player in multiworld.plando_item_blocks:
+ removed = []
+ for block in multiworld.plando_item_blocks[player]:
+ locations = block.locations
+ resolved_locations = block.resolved_locations
+ worlds = block.worlds
if "early_locations" in locations:
- locations.remove("early_locations")
- for player in worlds:
- locations += early_locations[player]
+ for target_player in worlds:
+ resolved_locations += early_locations[target_player]
if "non_early_locations" in locations:
- locations.remove("non_early_locations")
- for player in worlds:
- locations += non_early_locations[player]
-
- block['locations'] = locations
-
- if not block['count']:
- block['count'] = (min(len(block['items']), len(block['locations'])) if
- len(block['locations']) > 0 else len(block['items']))
- if isinstance(block['count'], int):
- block['count'] = {'min': block['count'], 'max': block['count']}
- if 'min' not in block['count']:
- block['count']['min'] = 0
- if 'max' not in block['count']:
- block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
- len(block['locations']) > 0 else len(block['items']))
- if block['count']['max'] > len(block['items']):
- count = block['count']
- failed(f"Plando count {count} greater than items specified", block['force'])
- block['count'] = len(block['items'])
- if block['count']['max'] > len(block['locations']) > 0:
- count = block['count']
- failed(f"Plando count {count} greater than locations specified", block['force'])
- block['count'] = len(block['locations'])
- block['count']['target'] = world.random.randint(block['count']['min'], block['count']['max'])
-
- if block['count']['target'] > 0:
- plando_blocks.append(block)
+ for target_player in worlds:
+ resolved_locations += non_early_locations[target_player]
+
+ if block.count["max"] > len(block.items):
+ count = block.count["max"]
+ failed(f"Plando count {count} greater than items specified", block.force)
+ block.count["max"] = len(block.items)
+ if block.count["min"] > len(block.items):
+ block.count["min"] = len(block.items)
+ if block.count["max"] > len(block.resolved_locations) > 0:
+ count = block.count["max"]
+ failed(f"Plando count {count} greater than locations specified", block.force)
+ block.count["max"] = len(block.resolved_locations)
+ if block.count["min"] > len(block.resolved_locations):
+ block.count["min"] = len(block.resolved_locations)
+ block.count["target"] = multiworld.random.randint(block.count["min"],
+ block.count["max"])
+
+ if not block.count["target"]:
+ removed.append(block)
+
+ for block in removed:
+ multiworld.plando_item_blocks[player].remove(block)
+
+
+def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
+ def warn(warning: str, force: bool | str) -> None:
+ if isinstance(force, bool):
+ logging.warning(f"{warning}")
+ else:
+ logging.debug(f"{warning}")
+
+ def failed(warning: str, force: bool | str) -> None:
+ if force is True:
+ raise Exception(warning)
+ else:
+ warn(warning, force)
# shuffle, but then sort blocks by number of locations minus number of items,
# so less-flexible blocks get priority
- world.random.shuffle(plando_blocks)
- plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
- if len(block['locations']) > 0
- else len(world.get_unfilled_locations(player)) - block['count']['target']))
-
+ multiworld.random.shuffle(plando_blocks)
+ plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"]
+ if len(block.resolved_locations) > 0
+ else len(multiworld.get_unfilled_locations(block.player)) -
+ block.count["target"]))
for placement in plando_blocks:
- player = placement['player']
+ player = placement.player
try:
- worlds = placement['world']
- locations = placement['locations']
- items = placement['items']
- maxcount = placement['count']['target']
- from_pool = placement['from_pool']
-
- candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
- world.random.shuffle(candidates)
- world.random.shuffle(items)
- count = 0
- err: typing.List[str] = []
- successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
- for item_name in items:
- item = world.worlds[player].create_item(item_name)
- for location in reversed(candidates):
- if location in key_drop_data:
- warn(
- f"Can't place '{item_name}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
- continue
- if not location.item:
- if location.item_rule(item):
- if location.can_fill(world.state, item, False):
- successful_pairs.append((item, location))
- candidates.remove(location)
- count = count + 1
- break
- else:
- err.append(f"Can't place item at {location} due to fill condition not met.")
- else:
- err.append(f"{item_name} not allowed at {location}.")
+ worlds = placement.worlds
+ locations = placement.resolved_locations
+ items = placement.items
+ maxcount = placement.count["target"]
+ from_pool = placement.from_pool
+
+ item_candidates = []
+ if from_pool:
+ instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
+ for item in multiworld.random.sample(items, maxcount):
+ candidate = next((i for i in instances if i.name == item), None)
+ if candidate is None:
+ warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
+ f"it's already missing from it", placement.force)
+ candidate = multiworld.worlds[player].create_item(item)
else:
- err.append(f"Cannot place {item_name} into already filled location {location}.")
- if count == maxcount:
- break
- if count < placement['count']['min']:
- m = placement['count']['min']
- failed(
- f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}",
- placement['force'])
- for (item, location) in successful_pairs:
- world.push_item(location, item, collect=False)
- location.event = True # flag location to be checked during fill
- location.locked = True
- logging.debug(f"Plando placed {item} at {location}")
- if from_pool:
- try:
- world.itempool.remove(item)
- except ValueError:
- warn(
- f"Could not remove {item} from pool for {world.player_name[player]} as it's already missing from it.",
- placement['force'])
-
+ multiworld.itempool.remove(candidate)
+ instances.remove(candidate)
+ item_candidates.append(candidate)
+ else:
+ item_candidates = [multiworld.worlds[player].create_item(item)
+ for item in multiworld.random.sample(items, maxcount)]
+ if any(item.code is None for item in item_candidates) \
+ and not all(item.code is None for item in item_candidates):
+ failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
+ f"event items and non-event items. "
+ f"Event items: {[item for item in item_candidates if item.code is None]}, "
+ f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
+ placement.force)
+ continue
+ else:
+ is_real = item_candidates[0].code is not None
+ candidates = [candidate for candidate in locations if candidate.item is None
+ and bool(candidate.address) == is_real]
+ multiworld.random.shuffle(candidates)
+ allstate = multiworld.get_all_state(False)
+ mincount = placement.count["min"]
+ allowed_margin = len(item_candidates) - mincount
+ fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True,
+ allow_partial=True, name="Plando Main Fill")
+
+ if len(item_candidates) > allowed_margin:
+ failed(f"Could not place {len(item_candidates)} "
+ f"of {mincount + allowed_margin} item(s) "
+ f"for {multiworld.player_name[player]}, "
+ f"remaining items: {item_candidates}",
+ placement.force)
+ if from_pool:
+ multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
except Exception as e:
raise Exception(
- f"Error running plando for player {player} ({world.player_name[player]})") from e
+ f"Error running plando for player {player} ({multiworld.player_name[player]})") from e
diff --git a/Generate.py b/Generate.py
index 5d44a1db4550..ae575a0a761f 100644
--- a/Generate.py
+++ b/Generate.py
@@ -1,79 +1,93 @@
from __future__ import annotations
import argparse
+import copy
import logging
import os
import random
import string
+import sys
import urllib.parse
import urllib.request
-from collections import ChainMap, Counter
-from typing import Any, Callable, Dict, Tuple, Union
+from collections import Counter
+from itertools import chain
+from typing import Any
import ModuleUpdate
ModuleUpdate.update()
-import copy
import Utils
import Options
from BaseClasses import seeddigits, get_seed, PlandoOptions
-from Main import main as ERmain
-from settings import get_settings
-from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path
-from worlds.alttp import Options as LttPOptions
-from worlds.alttp.EntranceRandomizer import parse_arguments
-from worlds.alttp.Text import TextTable
-from worlds.AutoWorld import AutoWorldRegister
-from worlds.generic import PlandoConnection
+from Utils import parse_yamls, version_tuple, __version__, tuplize_version
-def mystery_argparse():
- options = get_settings()
- defaults = options.generator
+def mystery_argparse(argv: list[str] | None = None):
+ from settings import get_settings
+ settings = get_settings()
+ defaults = settings.generator
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
- help='Path to the weights file to use for rolling game settings, urls are also valid')
- parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
+ help='Path to the weights file to use for rolling game options, urls are also valid')
+ parser.add_argument('--sameoptions', help='Rolls options per weights file rather than per player',
action='store_true')
parser.add_argument('--player_files_path', default=defaults.player_files_path,
help="Input directory for player files.")
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1))
parser.add_argument('--spoiler', type=int, default=defaults.spoiler)
- parser.add_argument('--outputpath', default=options.general_options.output_path,
+ parser.add_argument('--outputpath', default=settings.general_options.output_path,
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
parser.add_argument('--race', action='store_true', default=defaults.race)
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
- parser.add_argument('--log_level', default='info', help='Sets log level')
- parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
- help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
- parser.add_argument('--plando', default=defaults.plando_options,
- help='List of options that can be set manually. Can be combined, for example "bosses, items"')
+ parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level')
+ parser.add_argument('--log_time', help="Add timestamps to STDOUT",
+ default=defaults.logtime, action='store_true')
+ parser.add_argument("--csv_output", action="store_true",
+ help="Output rolled player options to csv (made for async multiworld).")
+ parser.add_argument("--plando", default=defaults.plando_options,
+ help="List of options that can be set manually. Can be combined, for example \"bosses, items\"")
parser.add_argument("--skip_prog_balancing", action="store_true",
help="Skip progression balancing step during generation.")
- args = parser.parse_args()
+ parser.add_argument("--skip_output", action="store_true",
+ help="Skips generation assertion and output stages and skips multidata and spoiler output. "
+ "Intended for debugging and testing purposes.")
+ parser.add_argument("--spoiler_only", action="store_true",
+ help="Skips generation assertion and multidata, outputting only a spoiler log. "
+ "Intended for debugging and testing purposes.")
+ args = parser.parse_args(argv)
+
+ if args.skip_output and args.spoiler_only:
+ parser.error("Cannot mix --skip_output and --spoiler_only")
+ elif args.spoiler == 0 and args.spoiler_only:
+ parser.error("Cannot use --spoiler_only when --spoiler=0. Use --skip_output or set --spoiler to a different value")
+
if not os.path.isabs(args.weights_file_path):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
- return args, options
+
+ return args
def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
-def main(args=None, callback=ERmain):
+def main(args=None) -> tuple[argparse.Namespace, int]:
+ # __name__ == "__main__" check so unittests that already imported worlds don't trip this.
+ if __name__ == "__main__" and "worlds" in sys.modules:
+ raise Exception("Worlds system should not be loaded before logging init.")
+
if not args:
- args, options = mystery_argparse()
- else:
- options = get_settings()
+ args = mystery_argparse()
seed = get_seed(args.seed)
- Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
+
+ Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
random.seed(seed)
seed_name = get_seed_name(random)
@@ -81,7 +95,7 @@ def main(args=None, callback=ERmain):
logging.info("Race mode enabled. Using non-deterministic random source.")
random.seed() # reset to time-based random source
- weights_cache: Dict[str, Tuple[Any, ...]] = {}
+ weights_cache: dict[str, tuple[Any, ...]] = {}
if args.weights_file_path and os.path.exists(args.weights_file_path):
try:
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
@@ -100,24 +114,33 @@ def main(args=None, callback=ERmain):
del(meta_weights["meta_description"])
except Exception as e:
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
- if args.samesettings:
- raise Exception("Cannot mix --samesettings with --meta")
+ if args.sameoptions:
+ raise Exception("Cannot mix --sameoptions with --meta")
else:
meta_weights = None
+
+
player_id = 1
player_files = {}
for file in os.scandir(args.player_files_path):
fname = file.name
- if file.is_file() and not fname.startswith(".") and \
+ if file.is_file() and not fname.startswith(".") and not fname.lower().endswith(".ini") 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:
- weights_cache[fname] = read_weights_yamls(path)
+ weights_for_file = []
+ for doc_idx, yaml in enumerate(read_weights_yamls(path)):
+ if yaml is None:
+ logging.warning(f"Ignoring empty yaml document #{doc_idx + 1} in {fname}")
+ else:
+ weights_for_file.append(yaml)
+ weights_cache[fname] = tuple(weights_for_file)
+
except Exception as e:
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
# sort dict for consistent results across platforms:
- weights_cache = {key: value for key, value in sorted(weights_cache.items())}
+ weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())}
for filename, yaml_data in weights_cache.items():
if filename not in {args.meta_file_path, args.weights_file_path}:
for yaml in yaml_data:
@@ -127,6 +150,13 @@ def main(args=None, callback=ERmain):
player_id += 1
args.multi = max(player_id - 1, args.multi)
+
+ if args.multi == 0:
+ raise ValueError(
+ "No individual player files found and number of players is 0. "
+ "Provide individual player files or specify the number of players via host.yaml or --multi."
+ )
+
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
f"{seed_name} Seed {seed} with plando: {args.plando}")
@@ -134,18 +164,15 @@ def main(args=None, callback=ERmain):
raise Exception(f"No weights found. "
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
f"A mix is also permitted.")
- erargs = parse_arguments(['--multi', str(args.multi)])
- erargs.seed = seed
- erargs.plando_options = args.plando
- erargs.glitch_triforce = options.generator.glitch_triforce_room
- erargs.spoiler = args.spoiler
- erargs.race = args.race
- erargs.outputname = seed_name
- erargs.outputpath = args.outputpath
- erargs.skip_prog_balancing = args.skip_prog_balancing
-
- settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
- {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
+
+ from worlds.AutoWorld import AutoWorldRegister
+ args.outputname = seed_name
+ args.sprite = dict.fromkeys(range(1, args.multi+1), None)
+ args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
+ args.name = {}
+
+ settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
+ {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
for fname, yamls in weights_cache.items()}
if meta_weights:
@@ -157,10 +184,16 @@ def main(args=None, callback=ERmain):
for yaml in weights_cache[path]:
if category_name is None:
for category in yaml:
- if category in AutoWorldRegister.world_types and key in Options.common_options:
+ if category in AutoWorldRegister.world_types and \
+ key in Options.CommonOptions.type_hints:
yaml[category][key] = option
elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
+ elif key == "triggers":
+ if "triggers" not in yaml[category_name]:
+ yaml[category_name][key] = []
+ for trigger in option:
+ yaml[category_name][key].append(trigger)
else:
yaml[category_name][key] = option
@@ -168,30 +201,34 @@ def main(args=None, callback=ERmain):
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter()
- erargs.player_settings = {}
+ args.player_options = {}
player = 1
while player <= args.multi:
path = player_path_cache[player]
if path:
try:
- settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
+ settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
for settingsObject in settings:
for k, v in vars(settingsObject).items():
if v is not None:
try:
- getattr(erargs, k)[player] = v
+ getattr(args, k)[player] = v
except AttributeError:
- setattr(erargs, k, {player: v})
+ setattr(args, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
- if path == args.weights_file_path: # if name came from the weights file, just use base player name
- erargs.name[player] = f"Player{player}"
- 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)
+ # name was not specified
+ if player not in args.name:
+ if path == args.weights_file_path:
+ # weights file, so we need to make the name unique
+ args.name[player] = f"Player{player}"
+ else:
+ # use the filename
+ args.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
+ args.name[player] = handle_name(args.name[player], player, name_counter)
player += 1
except Exception as e:
@@ -199,35 +236,13 @@ def main(args=None, callback=ERmain):
else:
raise RuntimeError(f'No weights specified for player {player}')
- if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
- raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
-
- if args.yaml_output:
- import yaml
- important = {}
- for option, player_settings in vars(erargs).items():
- if type(player_settings) == dict:
- if all(type(value) != list for value in player_settings.values()):
- if len(player_settings.values()) > 1:
- important[option] = {player: value for player, value in player_settings.items() if
- player <= args.yaml_output}
- else:
- logging.debug(f"No player settings defined for option '{option}'")
-
- else:
- if player_settings != "": # is not empty name
- important[option] = player_settings
- else:
- logging.debug(f"No player settings defined for option '{option}'")
- if args.outputpath:
- os.makedirs(args.outputpath, exist_ok=True)
- with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
- yaml.dump(important, f)
+ if len(set(name.lower() for name in args.name.values())) != len(args.name):
+ raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in args.name.values())}")
- callback(erargs, seed)
+ return args, seed
-def read_weights_yamls(path) -> Tuple[Any, ...]:
+def read_weights_yamls(path) -> tuple[Any, ...]:
try:
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
@@ -237,7 +252,20 @@ def read_weights_yamls(path) -> Tuple[Any, ...]:
except Exception as e:
raise Exception(f"Failed to read weights ({path})") from e
- return tuple(parse_yamls(yaml))
+ from yaml.error import MarkedYAMLError
+ try:
+ return tuple(parse_yamls(yaml))
+ except MarkedYAMLError as ex:
+ if ex.problem_mark:
+ lines = yaml.splitlines()
+ if ex.context_mark:
+ relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
+ else:
+ relevant_lines = lines[ex.problem_mark.line]
+ error_line = " " * ex.problem_mark.column + "^"
+ raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
+ f"\n{relevant_lines}\n{error_line}")
+ raise ex
def interpret_on_off(value) -> bool:
@@ -277,84 +305,101 @@ def get_choice(option, root, value=None) -> Any:
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
-class SafeDict(dict):
- def __missing__(self, key):
- return '{' + key + '}'
+class SafeFormatter(string.Formatter):
+ def get_value(self, key, args, kwargs):
+ if isinstance(key, int):
+ if key < len(args):
+ return args[key]
+ else:
+ return "{" + str(key) + "}"
+ else:
+ return kwargs.get(key, "{" + key + "}")
def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name.lower()] += 1
number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
- new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
- NUMBER=(number if number > 1 else ''),
- player=player,
- PLAYER=(player if player > 1 else '')))
- new_name = new_name.strip()[:16]
+
+ new_name = SafeFormatter().vformat(new_name, (), {"number": number,
+ "NUMBER": (number if number > 1 else ''),
+ "player": player,
+ "PLAYER": (player if player > 1 else '')})
+ # Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
+ # Could cause issues for some clients that cannot handle the additional whitespace.
+ new_name = new_name.strip()[:16].strip()
+
if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name
-def prefer_int(input_data: str) -> Union[str, int]:
- try:
- return int(input_data)
- except:
- return input_data
-
-
-goals = {
- 'ganon': 'ganon',
- 'crystals': 'crystals',
- 'bosses': 'bosses',
- 'pedestal': 'pedestal',
- 'ganon_pedestal': 'ganonpedestal',
- 'triforce_hunt': 'triforcehunt',
- 'local_triforce_hunt': 'localtriforcehunt',
- 'ganon_triforce_hunt': 'ganontriforcehunt',
- 'local_ganon_triforce_hunt': 'localganontriforcehunt',
- 'ice_rod_hunt': 'icerodhunt',
-}
-
-
-def roll_percentage(percentage: Union[int, float]) -> bool:
- """Roll a percentage chance.
- percentage is expected to be in range [0, 100]"""
- return random.random() < (float(percentage) / 100)
-
-
-def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
+def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
- new_options = set(new_weights) - set(weights)
- weights.update(new_weights)
+ cleaned_weights = {}
+ for option in new_weights:
+ option_name = option.lstrip("+-")
+ if option.startswith("+") and option_name in weights:
+ cleaned_value = weights[option_name]
+ new_value = new_weights[option]
+ if isinstance(new_value, set):
+ cleaned_value.update(new_value)
+ elif isinstance(new_value, list):
+ cleaned_value.extend(new_value)
+ elif isinstance(new_value, dict):
+ counter_value = Counter(cleaned_value)
+ counter_value.update(new_value)
+ cleaned_value = dict(counter_value)
+ else:
+ raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
+ f" received {type(new_value).__name__}.")
+ cleaned_weights[option_name] = cleaned_value
+ elif option.startswith("-") and option_name in weights:
+ cleaned_value = weights[option_name]
+ new_value = new_weights[option]
+ if isinstance(new_value, set):
+ cleaned_value.difference_update(new_value)
+ elif isinstance(new_value, list):
+ for element in new_value:
+ cleaned_value.remove(element)
+ elif isinstance(new_value, dict):
+ counter_value = Counter(cleaned_value)
+ counter_value.subtract(new_value)
+ cleaned_value = dict(counter_value)
+ else:
+ raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
+ f" received {type(new_value).__name__}.")
+ cleaned_weights[option_name] = cleaned_value
+ else:
+ # Options starting with + and - may modify values in-place, and new_weights may be shared by multiple slots
+ # using the same .yaml, so ensure that the new value is a copy.
+ cleaned_value = copy.deepcopy(new_weights[option])
+ cleaned_weights[option_name] = cleaned_value
+ new_options = set(cleaned_weights) - set(weights)
+ weights.update(cleaned_weights)
if new_options:
for new_option in new_options:
- logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
+ logging.warning(f'{update_type} Suboption "{new_option}" of "{name}" did not '
f'overwrite a root option. '
f'This is probably in error.')
return weights
-def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
+def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
+ from worlds import AutoWorldRegister
+
if not game:
return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types:
game_world = AutoWorldRegister.world_types[game]
- options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
+ options = game_world.options_dataclass.type_hints
if option_key in options:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return category_dict[option_key]
- if game == "A Link to the Past": # TODO wow i hate this
- if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
- "triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
- "triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
- "boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
- "red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
- "misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
- "random_sprite_on_event"}:
- return get_choice(option_key, category_dict)
- raise Exception(f"Error generating meta option {option_key} for {game}.")
+ if option_key == "triggers":
+ return category_dict[option_key]
+ raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
def roll_linked_options(weights: dict) -> dict:
@@ -363,7 +408,7 @@ def roll_linked_options(weights: dict) -> dict:
if "name" not in option_set:
raise ValueError("One of your linked options does not have a name.")
try:
- if roll_percentage(option_set["percentage"]):
+ if Options.roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.")
new_options = option_set["options"]
for category_name, category_options in new_options.items():
@@ -379,7 +424,7 @@ def roll_linked_options(weights: dict) -> dict:
return weights
-def roll_triggers(weights: dict, triggers: list) -> dict:
+def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
weights["_Generator_Version"] = Utils.__version__
for i, option_set in enumerate(triggers):
@@ -396,13 +441,13 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
trigger_result = get_choice("option_result", option_set)
result = get_choice(key, currently_targeted_weights)
currently_targeted_weights[key] = result
- if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
+ if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)):
for category_name, category_options in option_set["options"].items():
currently_targeted_weights = weights
if category_name:
currently_targeted_weights = currently_targeted_weights[category_name]
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
-
+ valid_keys.add(key)
except Exception as e:
raise ValueError(f"Your trigger number {i + 1} is invalid. "
f"Please fix your triggers.") from e
@@ -410,27 +455,39 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
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:
+ try:
+ if option_key in game_weights:
if not option.supports_weighting:
player_option = option.from_any(game_weights[option_key])
else:
player_option = option.from_any(get_choice(option_key, game_weights))
- setattr(ret, option_key, player_option)
- except Exception as e:
- raise Exception(f"Error generating option {option_key} in {ret.game}") from e
else:
- player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
+ player_option = option.from_any(option.default) # call the from_any here to support default "random"
+ setattr(ret, option_key, player_option)
+ except Exception as e:
+ raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
else:
- setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
+ from worlds import AutoWorldRegister
+ player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
+ """
+ Roll options from specified weights, usually originating from a .yaml options file.
+
+ Important note:
+ The same weights dict is shared between all slots using the same yaml (e.g. generic weights file for filler slots).
+ This means it should never be modified without making a deepcopy first.
+ """
+
+ from worlds import AutoWorldRegister
+
if "linked_options" in weights:
weights = roll_linked_options(weights)
+ valid_keys = {"triggers"}
if "triggers" in weights:
- weights = roll_triggers(weights, weights["triggers"])
+ weights = roll_triggers(weights, weights["triggers"], valid_keys)
requirements = weights.get("requires", {})
if requirements:
@@ -443,15 +500,39 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
if required_plando_options:
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
f"which is not enabled.")
-
+ games = requirements.get("game", {})
+ for game, version in games.items():
+ if game not in AutoWorldRegister.world_types:
+ continue
+ if not version:
+ raise Exception(f"Invalid version for game {game}: {version}.")
+ if isinstance(version, str):
+ version = {"min": version}
+ if "min" in version and tuplize_version(version["min"]) > AutoWorldRegister.world_types[game].world_version:
+ raise Exception(f"Settings reports required version of world \"{game}\" is at least {version['min']}, "
+ f"however world is of version "
+ f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
+ if "max" in version and tuplize_version(version["max"]) < AutoWorldRegister.world_types[game].world_version:
+ raise Exception(f"Settings reports required version of world \"{game}\" is no later than {version['max']}, "
+ f"however world is of version "
+ f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
ret = argparse.Namespace()
- for option_key in Options.per_game_common_options:
- if option_key in weights and option_key not in Options.common_options:
+ for option_key in Options.PerGameCommonOptions.type_hints:
+ if option_key in weights and option_key not in Options.CommonOptions.type_hints:
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
ret.game = get_choice("game", weights)
+ if not isinstance(ret.game, str):
+ if ret.game is None:
+ raise Exception('"game" not specified')
+ raise Exception(f"Invalid game: {ret.game}")
if ret.game not in AutoWorldRegister.world_types:
- picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0]
+ from worlds import failed_world_loads
+ picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
+ if picks[0] in failed_world_loads:
+ raise Exception(f"No functional world found to handle game {ret.game}. "
+ f"Did you mean '{picks[0]}' ({picks[1]}% sure)? "
+ f"If so, it appears the world failed to initialize correctly.")
raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? "
f"Check your spelling or installation of that world.")
@@ -461,161 +542,40 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
world_type = AutoWorldRegister.world_types[ret.game]
game_weights = weights[ret.game]
+ for weight in chain(game_weights, weights):
+ if weight.startswith("+"):
+ raise Exception(f"Merge tag cannot be used outside of trigger contexts. Found {weight}")
+ if weight.startswith("-"):
+ raise Exception(f"Remove tag cannot be used outside of trigger contexts. Found {weight}")
+
if "triggers" in game_weights:
- weights = roll_triggers(weights, game_weights["triggers"])
+ weights = roll_triggers(weights, game_weights["triggers"], valid_keys)
game_weights = weights[ret.game]
ret.name = get_choice('name', weights)
- for option_key, option in Options.common_options.items():
+ for option_key, option in Options.CommonOptions.type_hints.items():
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
- for option_key, option in world_type.option_definitions.items():
+ for option_key, option in world_type.options_dataclass.type_hints.items():
handle_option(ret, game_weights, option_key, option, plando_options)
- for option_key, option in Options.per_game_common_options.items():
- # skip setting this option if already set from common_options, defaulting to root option
- if option_key not in world_type.option_definitions and \
- (option_key not in Options.common_options or option_key in game_weights):
- handle_option(ret, game_weights, option_key, option, plando_options)
- if PlandoOptions.items in plando_options:
- ret.plando_items = game_weights.get("plando_items", [])
- if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
- # bad hardcoded behavior to make this work for now
- ret.plando_connections = []
- if PlandoOptions.connections in plando_options:
- options = game_weights.get("plando_connections", [])
- for placement in options:
- if roll_percentage(get_choice("percentage", placement, 100)):
- ret.plando_connections.append(PlandoConnection(
- get_choice("entrance", placement),
- get_choice("exit", placement),
- get_choice("direction", placement)
- ))
- elif ret.game == "A Link to the Past":
- roll_alttp_settings(ret, game_weights, plando_options)
+ valid_keys.add(option_key)
- return ret
+ if ret.game == "A Link to the Past":
+ # TODO there are still more LTTP options not on the options system
+ valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
+ roll_alttp_settings(ret, game_weights)
+ # log a warning for options within a game section that aren't determined as valid
+ for option_key in game_weights:
+ if option_key in valid_keys:
+ continue
+ logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers "
+ f"for player {ret.name}.")
+
+ return ret
-def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
- if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
- raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")
- glitches_required = get_choice_legacy('glitches_required', weights)
- if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
- logging.warning("Only NMG, OWG, HMG and No Logic supported")
- glitches_required = 'none'
- ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
- 'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
- glitches_required]
-
- ret.dark_room_logic = get_choice_legacy("dark_room_logic", weights, "lamp")
- if not ret.dark_room_logic: # None/False
- ret.dark_room_logic = "none"
- if ret.dark_room_logic == "sconces":
- ret.dark_room_logic = "torches"
- if ret.dark_room_logic not in {"lamp", "torches", "none"}:
- raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
-
- entrance_shuffle = get_choice_legacy('entrance_shuffle', weights, 'vanilla')
- if entrance_shuffle.startswith('none-'):
- ret.shuffle = 'vanilla'
- else:
- ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
-
- goal = get_choice_legacy('goals', weights, 'ganon')
-
- ret.goal = goals[goal]
-
-
- extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
-
- ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice_legacy('triforce_pieces_required', weights, 20))
-
- # sum a percentage to required
- if extra_pieces == 'percentage':
- percentage = max(100, float(get_choice_legacy('triforce_pieces_percentage', weights, 150))) / 100
- ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
- # vanilla mode (specify how many pieces are)
- elif extra_pieces == 'available':
- ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any(
- get_choice_legacy('triforce_pieces_available', weights, 30))
- # required pieces + fixed extra
- elif extra_pieces == 'extra':
- extra_pieces = max(0, int(get_choice_legacy('triforce_pieces_extra', weights, 10)))
- ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
-
- # change minimum to required pieces to avoid problems
- ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
-
- ret.shop_shuffle = get_choice_legacy('shop_shuffle', weights, '')
- if not ret.shop_shuffle:
- ret.shop_shuffle = ''
-
- ret.mode = get_choice_legacy("mode", weights)
-
- ret.difficulty = get_choice_legacy('item_pool', weights)
-
- ret.item_functionality = get_choice_legacy('item_functionality', weights)
-
-
- ret.enemy_damage = {None: 'default',
- 'default': 'default',
- 'shuffled': 'shuffled',
- 'random': 'chaos', # to be removed
- 'chaos': 'chaos',
- }[get_choice_legacy('enemy_damage', weights)]
-
- ret.enemy_health = get_choice_legacy('enemy_health', weights)
-
- ret.timer = {'none': False,
- None: False,
- False: False,
- 'timed': 'timed',
- 'timed_ohko': 'timed-ohko',
- 'ohko': 'ohko',
- 'timed_countdown': 'timed-countdown',
- 'display': 'display'}[get_choice_legacy('timer', weights, False)]
-
- ret.countdown_start_time = int(get_choice_legacy('countdown_start_time', weights, 10))
- ret.red_clock_time = int(get_choice_legacy('red_clock_time', weights, -2))
- ret.blue_clock_time = int(get_choice_legacy('blue_clock_time', weights, 2))
- ret.green_clock_time = int(get_choice_legacy('green_clock_time', weights, 4))
-
- ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default')
-
- ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g")
-
- ret.required_medallions = [get_choice_legacy("misery_mire_medallion", weights, "random"),
- get_choice_legacy("turtle_rock_medallion", weights, "random")]
-
- for index, medallion in enumerate(ret.required_medallions):
- ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
- .get(medallion.lower(), None)
- if not ret.required_medallions[index]:
- raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
-
- ret.plando_texts = {}
- if PlandoOptions.texts in plando_options:
- tt = TextTable()
- tt.removeUnwantedText()
- options = weights.get("plando_texts", [])
- for placement in options:
- if roll_percentage(get_choice_legacy("percentage", placement, 100)):
- at = str(get_choice_legacy("at", placement))
- if at not in tt:
- raise Exception(f"No text target \"{at}\" found.")
- ret.plando_texts[at] = str(get_choice_legacy("text", placement))
-
- ret.plando_connections = []
- if PlandoOptions.connections in plando_options:
- options = weights.get("plando_connections", [])
- for placement in options:
- if roll_percentage(get_choice_legacy("percentage", placement, 100)):
- ret.plando_connections.append(PlandoConnection(
- get_choice_legacy("entrance", placement),
- get_choice_legacy("exit", placement),
- get_choice_legacy("direction", placement, "both")
- ))
+def roll_alttp_settings(ret: argparse.Namespace, weights):
ret.sprite_pool = weights.get('sprite_pool', [])
ret.sprite = get_choice_legacy('sprite', weights, "Link")
if 'random_sprite_on_event' in weights:
@@ -643,6 +603,17 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
if __name__ == '__main__':
import atexit
confirmation = atexit.register(input, "Press enter to close.")
- main()
+ erargs, seed = main()
+ from Main import main as ERmain
+ multiworld = ERmain(erargs, seed)
+ if __debug__:
+ import gc
+ import sys
+ import weakref
+ weak = weakref.ref(multiworld)
+ del multiworld
+ gc.collect() # need to collect to deref all hard references
+ assert not weak(), f"MultiWorld object was not de-allocated, it's referenced {sys.getrefcount(weak())} times." \
+ " This would be a memory leak."
# in case of error-free exit should not need confirmation
atexit.unregister(confirmation)
diff --git a/KH2Client.py b/KH2Client.py
deleted file mode 100644
index 1134932dc26c..000000000000
--- a/KH2Client.py
+++ /dev/null
@@ -1,894 +0,0 @@
-import os
-import asyncio
-import ModuleUpdate
-import json
-import Utils
-from pymem import pymem
-from worlds.kh2.Items import exclusionItem_table, CheckDupingItems
-from worlds.kh2 import all_locations, item_dictionary_table, exclusion_table
-
-from worlds.kh2.WorldLocations import *
-
-from worlds import network_data_package
-
-if __name__ == "__main__":
- Utils.init_logging("KH2Client", exception_logger="Client")
-
-from NetUtils import ClientStatus
-from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
- CommonContext, server_loop
-
-ModuleUpdate.update()
-
-kh2_loc_name_to_id = network_data_package["games"]["Kingdom Hearts 2"]["location_name_to_id"]
-
-
-# class KH2CommandProcessor(ClientCommandProcessor):
-
-
-class KH2Context(CommonContext):
- # command_processor: int = KH2CommandProcessor
- game = "Kingdom Hearts 2"
- items_handling = 0b101 # Indicates you get items sent from other worlds.
-
- def __init__(self, server_address, password):
- super(KH2Context, self).__init__(server_address, password)
- self.kh2LocalItems = None
- self.ability = None
- self.growthlevel = None
- self.KH2_sync_task = None
- self.syncing = False
- self.kh2connected = False
- self.serverconneced = False
- self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()}
- self.location_name_to_data = {name: data for name, data, in all_locations.items()}
- self.lookup_id_to_item: typing.Dict[int, str] = {data.code: item_name for item_name, data in
- item_dictionary_table.items() if data.code}
- self.lookup_id_to_Location: typing.Dict[int, str] = {data.code: item_name for item_name, data in
- all_locations.items() if data.code}
- self.location_name_to_worlddata = {name: data for name, data, in all_world_locations.items()}
-
- self.location_table = {}
- self.collectible_table = {}
- self.collectible_override_flags_address = 0
- self.collectible_offsets = {}
- self.sending = []
- # list used to keep track of locations+items player has. Used for disoneccting
- self.kh2seedsave = None
- self.slotDataProgressionNames = {}
- self.kh2seedname = None
- self.kh2slotdata = None
- self.itemamount = {}
- # sora equipped, valor equipped, master equipped, final equipped
- self.keybladeAnchorList = (0x24F0, 0x32F4, 0x339C, 0x33D4)
- if "localappdata" in os.environ:
- self.game_communication_path = os.path.expandvars(r"%localappdata%\KH2AP")
- self.amountOfPieces = 0
- # hooked object
- self.kh2 = None
- self.ItemIsSafe = False
- self.game_connected = False
- self.finalxemnas = False
- self.worldid = {
- # 1: {}, # world of darkness (story cutscenes)
- 2: TT_Checks,
- # 3: {}, # destiny island doesn't have checks to ima put tt checks here
- 4: HB_Checks,
- 5: BC_Checks,
- 6: Oc_Checks,
- 7: AG_Checks,
- 8: LoD_Checks,
- 9: HundredAcreChecks,
- 10: PL_Checks,
- 11: DC_Checks, # atlantica isn't a supported world. if you go in atlantica it will check dc
- 12: DC_Checks,
- 13: TR_Checks,
- 14: HT_Checks,
- 15: HB_Checks, # world map, but you only go to the world map while on the way to goa so checking hb
- 16: PR_Checks,
- 17: SP_Checks,
- 18: TWTNW_Checks,
- # 255: {}, # starting screen
- }
- # 0x2A09C00+0x40 is the sve anchor. +1 is the last saved room
- self.sveroom = 0x2A09C00 + 0x41
- # 0 not in battle 1 in yellow battle 2 red battle #short
- self.inBattle = 0x2A0EAC4 + 0x40
- self.onDeath = 0xAB9078
- # PC Address anchors
- self.Now = 0x0714DB8
- self.Save = 0x09A70B0
- self.Sys3 = 0x2A59DF0
- self.Bt10 = 0x2A74880
- self.BtlEnd = 0x2A0D3E0
- self.Slot1 = 0x2A20C98
-
- self.chest_set = set(exclusion_table["Chests"])
-
- self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"])
- self.staff_set = set(CheckDupingItems["Weapons"]["Staffs"])
- self.shield_set = set(CheckDupingItems["Weapons"]["Shields"])
-
- self.all_weapons = self.keyblade_set.union(self.staff_set).union(self.shield_set)
-
- self.equipment_categories = CheckDupingItems["Equipment"]
- self.armor_set = set(self.equipment_categories["Armor"])
- self.accessories_set = set(self.equipment_categories["Accessories"])
- self.all_equipment = self.armor_set.union(self.accessories_set)
-
- self.Equipment_Anchor_Dict = {
- "Armor": [0x2504, 0x2506, 0x2508, 0x250A],
- "Accessories": [0x2514, 0x2516, 0x2518, 0x251A]}
-
- self.AbilityQuantityDict = {}
- self.ability_categories = CheckDupingItems["Abilities"]
-
- self.sora_ability_set = set(self.ability_categories["Sora"])
- self.donald_ability_set = set(self.ability_categories["Donald"])
- self.goofy_ability_set = set(self.ability_categories["Goofy"])
-
- self.all_abilities = self.sora_ability_set.union(self.donald_ability_set).union(self.goofy_ability_set)
-
- self.boost_set = set(CheckDupingItems["Boosts"])
- self.stat_increase_set = set(CheckDupingItems["Stat Increases"])
- self.AbilityQuantityDict = {item: self.item_name_to_data[item].quantity for item in self.all_abilities}
- # Growth:[level 1,level 4,slot]
- self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25DA],
- "Quick Run": [0x62, 0x65, 0x25DC],
- "Dodge Roll": [0x234, 0x237, 0x25DE],
- "Aerial Dodge": [0x066, 0x069, 0x25E0],
- "Glide": [0x6A, 0x6D, 0x25E2]}
- self.boost_to_anchor_dict = {
- "Power Boost": 0x24F9,
- "Magic Boost": 0x24FA,
- "Defense Boost": 0x24FB,
- "AP Boost": 0x24F8}
-
- self.AbilityCodeList = [self.item_name_to_data[item].code for item in exclusionItem_table["Ability"]]
- self.master_growth = {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}
-
- self.bitmask_item_code = [
- 0x130000, 0x130001, 0x130002, 0x130003, 0x130004, 0x130005, 0x130006, 0x130007
- , 0x130008, 0x130009, 0x13000A, 0x13000B, 0x13000C
- , 0x13001F, 0x130020, 0x130021, 0x130022, 0x130023
- , 0x13002A, 0x13002B, 0x13002C, 0x13002D]
-
- async def server_auth(self, password_requested: bool = False):
- if password_requested and not self.password:
- await super(KH2Context, self).server_auth(password_requested)
- await self.get_username()
- await self.send_connect()
-
- async def connection_closed(self):
- self.kh2connected = False
- self.serverconneced = False
- if self.kh2seedname is not None and self.auth is not None:
- with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
- 'w') as f:
- f.write(json.dumps(self.kh2seedsave, indent=4))
- await super(KH2Context, self).connection_closed()
-
- async def disconnect(self, allow_autoreconnect: bool = False):
- self.kh2connected = False
- self.serverconneced = False
- if self.kh2seedname not in {None} and self.auth not in {None}:
- with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
- 'w') as f:
- f.write(json.dumps(self.kh2seedsave, indent=4))
- await super(KH2Context, self).disconnect()
-
- @property
- def endpoints(self):
- if self.server:
- return [self.server]
- else:
- return []
-
- async def shutdown(self):
- if self.kh2seedname not in {None} and self.auth not in {None}:
- with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
- 'w') as f:
- f.write(json.dumps(self.kh2seedsave, indent=4))
- await super(KH2Context, self).shutdown()
-
- def on_package(self, cmd: str, args: dict):
- if cmd in {"RoomInfo"}:
- self.kh2seedname = args['seed_name']
- if not os.path.exists(self.game_communication_path):
- os.makedirs(self.game_communication_path)
- if not os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
- self.kh2seedsave = {"itemIndex": -1,
- # back of soras invo is 0x25E2. Growth should be moved there
- # Character: [back of invo, front of invo]
- "SoraInvo": [0x25D8, 0x2546],
- "DonaldInvo": [0x26F4, 0x2658],
- "GoofyInvo": [0x280A, 0x276C],
- "AmountInvo": {
- "ServerItems": {
- "Ability": {},
- "Amount": {},
- "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
- "Aerial Dodge": 0,
- "Glide": 0},
- "Bitmask": [],
- "Weapon": {"Sora": [], "Donald": [], "Goofy": []},
- "Equipment": [],
- "Magic": {},
- "StatIncrease": {},
- "Boost": {},
- },
- "LocalItems": {
- "Ability": {},
- "Amount": {},
- "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
- "Aerial Dodge": 0, "Glide": 0},
- "Bitmask": [],
- "Weapon": {"Sora": [], "Donald": [], "Goofy": []},
- "Equipment": [],
- "Magic": {},
- "StatIncrease": {},
- "Boost": {},
- }},
- # 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked
- "LocationsChecked": [],
- "Levels": {
- "SoraLevel": 0,
- "ValorLevel": 0,
- "WisdomLevel": 0,
- "LimitLevel": 0,
- "MasterLevel": 0,
- "FinalLevel": 0,
- },
- "SoldEquipment": [],
- "SoldBoosts": {"Power Boost": 0,
- "Magic Boost": 0,
- "Defense Boost": 0,
- "AP Boost": 0}
- }
- with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
- 'wt') as f:
- pass
- self.locations_checked = set()
- elif os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
- with open(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json", 'r') as f:
- self.kh2seedsave = json.load(f)
- self.locations_checked = set(self.kh2seedsave["LocationsChecked"])
- self.serverconneced = True
-
- if cmd in {"Connected"}:
- self.kh2slotdata = args['slot_data']
- self.kh2LocalItems = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()}
- try:
- self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
- logger.info("You are now auto-tracking")
- self.kh2connected = True
- except Exception as e:
- logger.info("Line 247")
- if self.kh2connected:
- logger.info("Connection Lost")
- self.kh2connected = False
- logger.info(e)
-
- if cmd in {"ReceivedItems"}:
- start_index = args["index"]
- if start_index == 0:
- # resetting everything that were sent from the server
- self.kh2seedsave["SoraInvo"][0] = 0x25D8
- self.kh2seedsave["DonaldInvo"][0] = 0x26F4
- self.kh2seedsave["GoofyInvo"][0] = 0x280A
- self.kh2seedsave["itemIndex"] = - 1
- self.kh2seedsave["AmountInvo"]["ServerItems"] = {
- "Ability": {},
- "Amount": {},
- "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
- "Aerial Dodge": 0,
- "Glide": 0},
- "Bitmask": [],
- "Weapon": {"Sora": [], "Donald": [], "Goofy": []},
- "Equipment": [],
- "Magic": {},
- "StatIncrease": {},
- "Boost": {},
- }
- if start_index > self.kh2seedsave["itemIndex"]:
- self.kh2seedsave["itemIndex"] = start_index
- for item in args['items']:
- asyncio.create_task(self.give_item(item.item))
-
- if cmd in {"RoomUpdate"}:
- if "checked_locations" in args:
- new_locations = set(args["checked_locations"])
- # TODO: make this take locations from other players on the same slot so proper coop happens
- # items_to_give = [self.kh2slotdata["LocalItems"][str(location_id)] for location_id in new_locations if
- # location_id in self.kh2LocalItems.keys()]
- self.checked_locations |= new_locations
-
- async def checkWorldLocations(self):
- try:
- currentworldint = int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big")
- if currentworldint in self.worldid:
- curworldid = self.worldid[currentworldint]
- for location, data in curworldid.items():
- locationId = kh2_loc_name_to_id[location]
- if locationId not in self.locations_checked \
- and (int.from_bytes(
- self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
- "big") & 0x1 << data.bitIndex) > 0:
- self.sending = self.sending + [(int(locationId))]
- except Exception as e:
- logger.info("Line 285")
- if self.kh2connected:
- logger.info("Connection Lost.")
- self.kh2connected = False
- logger.info(e)
-
- async def checkLevels(self):
- try:
- for location, data in SoraLevels.items():
- currentLevel = int.from_bytes(
- self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), "big")
- locationId = kh2_loc_name_to_id[location]
- if locationId not in self.locations_checked \
- and currentLevel >= data.bitIndex:
- if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel:
- self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel
- self.sending = self.sending + [(int(locationId))]
- formDict = {
- 0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels],
- 3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels]}
- for i in range(5):
- for location, data in formDict[i][1].items():
- formlevel = int.from_bytes(
- self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big")
- locationId = kh2_loc_name_to_id[location]
- if locationId not in self.locations_checked \
- and formlevel >= data.bitIndex:
- if formlevel > self.kh2seedsave["Levels"][formDict[i][0]]:
- self.kh2seedsave["Levels"][formDict[i][0]] = formlevel
- self.sending = self.sending + [(int(locationId))]
- except Exception as e:
- logger.info("Line 312")
- if self.kh2connected:
- logger.info("Connection Lost.")
- self.kh2connected = False
- logger.info(e)
-
- async def checkSlots(self):
- try:
- for location, data in weaponSlots.items():
- locationId = kh2_loc_name_to_id[location]
- if locationId not in self.locations_checked:
- if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
- "big") > 0:
- self.sending = self.sending + [(int(locationId))]
-
- for location, data in formSlots.items():
- locationId = kh2_loc_name_to_id[location]
- if locationId not in self.locations_checked:
- if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
- "big") & 0x1 << data.bitIndex > 0:
- # self.locations_checked
- self.sending = self.sending + [(int(locationId))]
-
- except Exception as e:
- if self.kh2connected:
- logger.info("Line 333")
- logger.info("Connection Lost.")
- self.kh2connected = False
- logger.info(e)
-
- async def verifyChests(self):
- try:
- for location in self.locations_checked:
- locationName = self.lookup_id_to_Location[location]
- if locationName in self.chest_set:
- if locationName in self.location_name_to_worlddata.keys():
- locationData = self.location_name_to_worlddata[locationName]
- if int.from_bytes(
- self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
- "big") & 0x1 << locationData.bitIndex == 0:
- roomData = int.from_bytes(
- self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
- 1), "big")
- self.kh2.write_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
- (roomData | 0x01 << locationData.bitIndex).to_bytes(1, 'big'), 1)
-
- except Exception as e:
- if self.kh2connected:
- logger.info("Line 350")
- logger.info("Connection Lost.")
- self.kh2connected = False
- logger.info(e)
-
- async def verifyLevel(self):
- for leveltype, anchor in {"SoraLevel": 0x24FF,
- "ValorLevel": 0x32F6,
- "WisdomLevel": 0x332E,
- "LimitLevel": 0x3366,
- "MasterLevel": 0x339E,
- "FinalLevel": 0x33D6}.items():
- if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + anchor, 1), "big") < \
- self.kh2seedsave["Levels"][leveltype]:
- self.kh2.write_bytes(self.kh2.base_address + self.Save + anchor,
- (self.kh2seedsave["Levels"][leveltype]).to_bytes(1, 'big'), 1)
-
- async def give_item(self, item, ItemType="ServerItems"):
- try:
- itemname = self.lookup_id_to_item[item]
- itemcode = self.item_name_to_data[itemname]
- if itemcode.ability:
- abilityInvoType = 0
- TwilightZone = 2
- if ItemType == "LocalItems":
- abilityInvoType = 1
- TwilightZone = -2
- if itemname in {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}:
- self.kh2seedsave["AmountInvo"][ItemType]["Growth"][itemname] += 1
- return
-
- if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Ability"]:
- self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname] = []
- # appending the slot that the ability should be in
-
- if len(self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname]) < \
- self.AbilityQuantityDict[itemname]:
- if itemname in self.sora_ability_set:
- self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
- self.kh2seedsave["SoraInvo"][abilityInvoType])
- self.kh2seedsave["SoraInvo"][abilityInvoType] -= TwilightZone
- elif itemname in self.donald_ability_set:
- self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
- self.kh2seedsave["DonaldInvo"][abilityInvoType])
- self.kh2seedsave["DonaldInvo"][abilityInvoType] -= TwilightZone
- else:
- self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
- self.kh2seedsave["GoofyInvo"][abilityInvoType])
- self.kh2seedsave["GoofyInvo"][abilityInvoType] -= TwilightZone
-
- elif itemcode.code in self.bitmask_item_code:
-
- if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"]:
- self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"].append(itemname)
-
- elif itemcode.memaddr in {0x3594, 0x3595, 0x3596, 0x3597, 0x35CF, 0x35D0}:
-
- if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Magic"]:
- self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] += 1
- else:
- self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] = 1
- elif itemname in self.all_equipment:
-
- self.kh2seedsave["AmountInvo"][ItemType]["Equipment"].append(itemname)
-
- elif itemname in self.all_weapons:
- if itemname in self.keyblade_set:
- self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Sora"].append(itemname)
- elif itemname in self.staff_set:
- self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Donald"].append(itemname)
- else:
- self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Goofy"].append(itemname)
-
- elif itemname in self.boost_set:
- if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Boost"]:
- self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] += 1
- else:
- self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] = 1
-
- elif itemname in self.stat_increase_set:
-
- if itemname in self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"]:
- self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] += 1
- else:
- self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] = 1
-
- else:
- if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Amount"]:
- self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] += 1
- else:
- self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] = 1
-
- except Exception as e:
- if self.kh2connected:
- logger.info("Line 398")
- logger.info("Connection Lost.")
- self.kh2connected = False
- logger.info(e)
-
- def run_gui(self):
- """Import kivy UI system and start running it as self.ui_task."""
- from kvui import GameManager
-
- class KH2Manager(GameManager):
- logging_pairs = [
- ("Client", "Archipelago")
- ]
- base_title = "Archipelago KH2 Client"
-
- self.ui = KH2Manager(self)
- self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
-
- async def IsInShop(self, sellable, master_boost):
- # journal = 0x741230 shop = 0x741320
- # if journal=-1 and shop = 5 then in shop
- # if journam !=-1 and shop = 10 then journal
- journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
- shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
- if (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
- # print("your in the shop")
- sellable_dict = {}
- for itemName in sellable:
- itemdata = self.item_name_to_data[itemName]
- amount = int.from_bytes(
- self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
- sellable_dict[itemName] = amount
- while (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
- journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
- shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
- await asyncio.sleep(0.5)
- for item, amount in sellable_dict.items():
- itemdata = self.item_name_to_data[item]
- afterShop = int.from_bytes(
- self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
- if afterShop < amount:
- if item in master_boost:
- self.kh2seedsave["SoldBoosts"][item] += (amount - afterShop)
- else:
- self.kh2seedsave["SoldEquipment"].append(item)
-
- async def verifyItems(self):
- try:
- local_amount = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"].keys())
- server_amount = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"].keys())
- master_amount = local_amount | server_amount
-
- local_ability = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"].keys())
- server_ability = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"].keys())
- master_ability = local_ability | server_ability
-
- local_bitmask = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Bitmask"])
- server_bitmask = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Bitmask"])
- master_bitmask = local_bitmask | server_bitmask
-
- local_keyblade = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Sora"])
- local_staff = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Donald"])
- local_shield = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Goofy"])
-
- server_keyblade = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Sora"])
- server_staff = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Donald"])
- server_shield = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Goofy"])
-
- master_keyblade = local_keyblade | server_keyblade
- master_staff = local_staff | server_staff
- master_shield = local_shield | server_shield
-
- local_equipment = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Equipment"])
- server_equipment = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Equipment"])
- master_equipment = local_equipment | server_equipment
-
- local_magic = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"].keys())
- server_magic = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"].keys())
- master_magic = local_magic | server_magic
-
- local_stat = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"].keys())
- server_stat = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"].keys())
- master_stat = local_stat | server_stat
-
- local_boost = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"].keys())
- server_boost = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"].keys())
- master_boost = local_boost | server_boost
-
- master_sell = master_equipment | master_staff | master_shield | master_boost
- await asyncio.create_task(self.IsInShop(master_sell, master_boost))
- for itemName in master_amount:
- itemData = self.item_name_to_data[itemName]
- amountOfItems = 0
- if itemName in local_amount:
- amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"][itemName]
- if itemName in server_amount:
- amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"][itemName]
-
- if itemName == "Torn Page":
- # Torn Pages are handled differently because they can be consumed.
- # Will check the progression in 100 acre and - the amount of visits
- # amountofitems-amount of visits done
- for location, data in tornPageLocks.items():
- if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
- "big") & 0x1 << data.bitIndex > 0:
- amountOfItems -= 1
- if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
- "big") != amountOfItems and amountOfItems >= 0:
- self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
- amountOfItems.to_bytes(1, 'big'), 1)
-
- for itemName in master_keyblade:
- itemData = self.item_name_to_data[itemName]
- # if the inventory slot for that keyblade is less than the amount they should have
- if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
- "big") != 1 and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x1CFF, 1),
- "big") != 13:
- # Checking form anchors for the keyblade
- if self.kh2.read_short(self.kh2.base_address + self.Save + 0x24F0) == itemData.kh2id \
- or self.kh2.read_short(self.kh2.base_address + self.Save + 0x32F4) == itemData.kh2id \
- or self.kh2.read_short(self.kh2.base_address + self.Save + 0x339C) == itemData.kh2id \
- or self.kh2.read_short(self.kh2.base_address + self.Save + 0x33D4) == itemData.kh2id:
- self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
- (0).to_bytes(1, 'big'), 1)
- else:
- self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
- (1).to_bytes(1, 'big'), 1)
- for itemName in master_staff:
- itemData = self.item_name_to_data[itemName]
- if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
- "big") != 1 \
- and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2604) != itemData.kh2id \
- and itemName not in self.kh2seedsave["SoldEquipment"]:
- self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
- (1).to_bytes(1, 'big'), 1)
-
- for itemName in master_shield:
- itemData = self.item_name_to_data[itemName]
- if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
- "big") != 1 \
- and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2718) != itemData.kh2id \
- and itemName not in self.kh2seedsave["SoldEquipment"]:
- self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
- (1).to_bytes(1, 'big'), 1)
-
- for itemName in master_ability:
- itemData = self.item_name_to_data[itemName]
- ability_slot = []
- if itemName in local_ability:
- ability_slot += self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"][itemName]
- if itemName in server_ability:
- ability_slot += self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"][itemName]
- for slot in ability_slot:
- current = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
- ability = current & 0x0FFF
- if ability | 0x8000 != (0x8000 + itemData.memaddr):
- if current - 0x8000 > 0:
- self.kh2.write_short(self.kh2.base_address + self.Save + slot, (0x8000 + itemData.memaddr))
- else:
- self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr)
- # removes the duped ability if client gave faster than the game.
- for charInvo in {"SoraInvo", "DonaldInvo", "GoofyInvo"}:
- if self.kh2.read_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1]) != 0 and \
- self.kh2seedsave[charInvo][1] + 2 < self.kh2seedsave[charInvo][0]:
- self.kh2.write_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1], 0)
- # remove the dummy level 1 growths if they are in these invo slots.
- for inventorySlot in {0x25CE, 0x25D0, 0x25D2, 0x25D4, 0x25D6, 0x25D8}:
- current = self.kh2.read_short(self.kh2.base_address + self.Save + inventorySlot)
- ability = current & 0x0FFF
- if 0x05E <= ability <= 0x06D:
- self.kh2.write_short(self.kh2.base_address + self.Save + inventorySlot, 0)
-
- for itemName in self.master_growth:
- growthLevel = self.kh2seedsave["AmountInvo"]["ServerItems"]["Growth"][itemName] \
- + self.kh2seedsave["AmountInvo"]["LocalItems"]["Growth"][itemName]
- if growthLevel > 0:
- slot = self.growth_values_dict[itemName][2]
- min_growth = self.growth_values_dict[itemName][0]
- max_growth = self.growth_values_dict[itemName][1]
- if growthLevel > 4:
- growthLevel = 4
- current_growth_level = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
- ability = current_growth_level & 0x0FFF
- # if the player should be getting a growth ability
- if ability | 0x8000 != 0x8000 + min_growth - 1 + growthLevel:
- # if it should be level one of that growth
- if 0x8000 + min_growth - 1 + growthLevel <= 0x8000 + min_growth or ability < min_growth:
- self.kh2.write_short(self.kh2.base_address + self.Save + slot, min_growth)
- # if it is already in the inventory
- elif ability | 0x8000 < (0x8000 + max_growth):
- self.kh2.write_short(self.kh2.base_address + self.Save + slot, current_growth_level + 1)
-
- for itemName in master_bitmask:
- itemData = self.item_name_to_data[itemName]
- itemMemory = int.from_bytes(
- self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big")
- if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
- "big") & 0x1 << itemData.bitmask) == 0:
- # when getting a form anti points should be reset to 0 but bit-shift doesn't trigger the game.
- if itemName in {"Valor Form", "Wisdom Form", "Limit Form", "Master Form", "Final Form"}:
- self.kh2.write_bytes(self.kh2.base_address + self.Save + 0x3410,
- (0).to_bytes(1, 'big'), 1)
- self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
- (itemMemory | 0x01 << itemData.bitmask).to_bytes(1, 'big'), 1)
-
- for itemName in master_equipment:
- itemData = self.item_name_to_data[itemName]
- isThere = False
- if itemName in self.accessories_set:
- Equipment_Anchor_List = self.Equipment_Anchor_Dict["Accessories"]
- else:
- Equipment_Anchor_List = self.Equipment_Anchor_Dict["Armor"]
- # Checking form anchors for the equipment
- for slot in Equipment_Anchor_List:
- if self.kh2.read_short(self.kh2.base_address + self.Save + slot) == itemData.kh2id:
- isThere = True
- if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
- "big") != 0:
- self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
- (0).to_bytes(1, 'big'), 1)
- break
- if not isThere and itemName not in self.kh2seedsave["SoldEquipment"]:
- if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
- "big") != 1:
- self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
- (1).to_bytes(1, 'big'), 1)
-
- for itemName in master_magic:
- itemData = self.item_name_to_data[itemName]
- amountOfItems = 0
- if itemName in local_magic:
- amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"][itemName]
- if itemName in server_magic:
- amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"][itemName]
- if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
- "big") != amountOfItems \
- and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x741320, 1), "big") in {10, 8}:
- self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
- amountOfItems.to_bytes(1, 'big'), 1)
-
- for itemName in master_stat:
- itemData = self.item_name_to_data[itemName]
- amountOfItems = 0
- if itemName in local_stat:
- amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"][itemName]
- if itemName in server_stat:
- amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"][itemName]
-
- # 0x130293 is Crit_1's location id for touching the computer
- if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
- "big") != amountOfItems \
- and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Slot1 + 0x1B2, 1),
- "big") >= 5 and int.from_bytes(
- self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x23DF, 1),
- "big") > 0:
- self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
- amountOfItems.to_bytes(1, 'big'), 1)
-
- for itemName in master_boost:
- itemData = self.item_name_to_data[itemName]
- amountOfItems = 0
- if itemName in local_boost:
- amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"][itemName]
- if itemName in server_boost:
- amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"][itemName]
- amountOfBoostsInInvo = int.from_bytes(
- self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
- "big")
- amountOfUsedBoosts = int.from_bytes(
- self.kh2.read_bytes(self.kh2.base_address + self.Save + self.boost_to_anchor_dict[itemName], 1),
- "big")
- # Ap Boots start at +50 for some reason
- if itemName == "AP Boost":
- amountOfUsedBoosts -= 50
- totalBoosts = (amountOfBoostsInInvo + amountOfUsedBoosts)
- if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][
- itemName] and amountOfBoostsInInvo < 255:
- self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
- (amountOfBoostsInInvo + 1).to_bytes(1, 'big'), 1)
-
- except Exception as e:
- logger.info("Line 573")
- if self.kh2connected:
- logger.info("Connection Lost.")
- self.kh2connected = False
- logger.info(e)
-
-
-def finishedGame(ctx: KH2Context, message):
- if ctx.kh2slotdata['FinalXemnas'] == 1:
- if 0x1301ED in message[0]["locations"]:
- ctx.finalxemnas = True
- # three proofs
- if ctx.kh2slotdata['Goal'] == 0:
- if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, 1), "big") > 0 \
- and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, 1), "big") > 0 \
- and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, 1), "big") > 0:
- if ctx.kh2slotdata['FinalXemnas'] == 1:
- if ctx.finalxemnas:
- return True
- else:
- return False
- else:
- return True
- else:
- return False
- elif ctx.kh2slotdata['Goal'] == 1:
- if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x3641, 1), "big") >= \
- ctx.kh2slotdata['LuckyEmblemsRequired']:
- ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
- ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
- ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
- if ctx.kh2slotdata['FinalXemnas'] == 1:
- if ctx.finalxemnas:
- return True
- else:
- return False
- else:
- return True
- else:
- return False
- elif ctx.kh2slotdata['Goal'] == 2:
- for boss in ctx.kh2slotdata["hitlist"]:
- if boss in message[0]["locations"]:
- ctx.amountOfPieces += 1
- if ctx.amountOfPieces >= ctx.kh2slotdata["BountyRequired"]:
- ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
- ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
- ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
- if ctx.kh2slotdata['FinalXemnas'] == 1:
- if ctx.finalxemnas:
- return True
- else:
- return False
- else:
- return True
- else:
- return False
-
-
-async def kh2_watcher(ctx: KH2Context):
- while not ctx.exit_event.is_set():
- try:
- if ctx.kh2connected and ctx.serverconneced:
- ctx.sending = []
- await asyncio.create_task(ctx.checkWorldLocations())
- await asyncio.create_task(ctx.checkLevels())
- await asyncio.create_task(ctx.checkSlots())
- await asyncio.create_task(ctx.verifyChests())
- await asyncio.create_task(ctx.verifyItems())
- await asyncio.create_task(ctx.verifyLevel())
- message = [{"cmd": 'LocationChecks', "locations": ctx.sending}]
- if finishedGame(ctx, message):
- await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
- ctx.finished_game = True
- location_ids = []
- location_ids = [location for location in message[0]["locations"] if location not in location_ids]
- for location in location_ids:
- if location not in ctx.locations_checked:
- ctx.locations_checked.add(location)
- ctx.kh2seedsave["LocationsChecked"].append(location)
- if location in ctx.kh2LocalItems:
- item = ctx.kh2slotdata["LocalItems"][str(location)]
- await asyncio.create_task(ctx.give_item(item, "LocalItems"))
- await ctx.send_msgs(message)
- elif not ctx.kh2connected and ctx.serverconneced:
- logger.info("Game is not open. Disconnecting from Server.")
- await ctx.disconnect()
- except Exception as e:
- logger.info("Line 661")
- if ctx.kh2connected:
- logger.info("Connection Lost.")
- ctx.kh2connected = False
- logger.info(e)
- await asyncio.sleep(0.5)
-
-
-if __name__ == '__main__':
- async def main(args):
- ctx = KH2Context(args.connect, args.password)
- ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
- if gui_enabled:
- ctx.run_gui()
- ctx.run_cli()
- progression_watcher = asyncio.create_task(
- kh2_watcher(ctx), name="KH2ProgressionWatcher")
-
- await ctx.exit_event.wait()
- ctx.server_address = None
-
- await progression_watcher
-
- await ctx.shutdown()
-
-
- import colorama
-
- parser = get_base_parser(description="KH2 Client, for text interfacing.")
-
- args, rest = parser.parse_known_args()
- colorama.init()
- asyncio.run(main(args))
- colorama.deinit()
diff --git a/LICENSE b/LICENSE
index 40716cff4275..60d31b7b7de8 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,7 +1,7 @@
MIT License
Copyright (c) 2017 LLCoolDave
-Copyright (c) 2022 Berserker66
+Copyright (c) 2025 Berserker66
Copyright (c) 2022 CaitSith2
Copyright (c) 2021 LegendaryLinux
diff --git a/Launcher.py b/Launcher.py
index a1548d594ce8..89421ff30508 100644
--- a/Launcher.py
+++ b/Launcher.py
@@ -1,74 +1,91 @@
"""
-Archipelago launcher for bundled app.
+Archipelago Launcher
-* if run with APBP as argument, launch corresponding client.
-* if run with executable as argument, run it passing argv[2:] as arguments
-* if run without arguments, open launcher GUI
+* If run with a patch file as argument, launch corresponding client with the patch file as an argument.
+* If run with component name as argument, run it passing argv[2:] as arguments.
+* If run without arguments or unknown arguments, open launcher GUI.
-Scroll down to components= to add components to the launcher as well as setup.py
+Additional components can be added to worlds.LauncherComponents.components.
"""
-
import argparse
-import itertools
import logging
import multiprocessing
+import os
import shlex
import subprocess
import sys
+import urllib.parse
import webbrowser
+from collections.abc import Callable, Sequence
from os.path import isfile
from shutil import which
-from typing import Sequence, Union, Optional
-
-import Utils
-import settings
-from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
+from typing import Any
if __name__ == "__main__":
import ModuleUpdate
+
ModuleUpdate.update()
-from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
- is_windows, is_macos, is_linux
+import settings
+import Utils
+from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
+ user_path)
+from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
def open_host_yaml():
- file = settings.get_settings().filename
+ s = settings.get_settings()
+ file = s.filename
+ s.save()
assert file, "host.yaml missing"
if is_linux:
exe = which('sensible-editor') or which('gedit') or \
which('xdg-open') or which('gnome-open') or which('kde-open')
- subprocess.Popen([exe, file])
elif is_macos:
exe = which("open")
- subprocess.Popen([exe, file])
else:
webbrowser.open(file)
+ return
+ env = os.environ
+ if "LD_LIBRARY_PATH" in env:
+ env = env.copy()
+ del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
+ subprocess.Popen([exe, file], env=env)
def open_patch():
suffixes = []
for c in components:
- if isfile(get_exe(c)[-1]):
- suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
- isinstance(c.file_identifier, SuffixIdentifier) else []
+ if c.type == Type.CLIENT and \
+ isinstance(c.file_identifier, SuffixIdentifier) and \
+ (c.script_name is None or isfile(get_exe(c)[-1])):
+ suffixes += c.file_identifier.suffixes
try:
- filename = open_filename('Select patch', (('Patches', suffixes),))
+ filename = open_filename("Select patch", (("Patches", suffixes),))
except Exception as e:
- messagebox('Error', str(e), error=True)
+ messagebox("Error", str(e), error=True)
else:
file, component = identify(filename)
if file and component:
- launch([*get_exe(component), file], component.cli)
+ exe = get_exe(component)
+ if exe is None or not isfile(exe[-1]):
+ exe = get_exe("Launcher")
+
+ launch([*exe, file], component.cli)
-def generate_yamls():
+def generate_yamls(*args):
from Options import generate_yaml_templates
+ parser = argparse.ArgumentParser(description="Generate Template Options", usage="[-h] [--skip_open_folder]")
+ parser.add_argument("--skip_open_folder", action="store_true")
+ args = parser.parse_args(args)
+
target = Utils.user_path("Players", "Templates")
generate_yaml_templates(target, False)
- open_folder(target)
+ if not args.skip_open_folder:
+ open_folder(target)
def browse_files():
@@ -78,12 +95,20 @@ def browse_files():
def open_folder(folder_path):
if is_linux:
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
- subprocess.Popen([exe, folder_path])
elif is_macos:
exe = which("open")
- subprocess.Popen([exe, folder_path])
else:
webbrowser.open(folder_path)
+ return
+
+ if exe:
+ env = os.environ
+ if "LD_LIBRARY_PATH" in env:
+ env = env.copy()
+ del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
+ subprocess.Popen([exe, folder_path], env=env)
+ else:
+ logging.warning(f"No file browser available to open {folder_path}")
def update_settings():
@@ -93,55 +118,91 @@ def update_settings():
components.extend([
# Functions
- Component("Open host.yaml", func=open_host_yaml),
- Component("Open Patch", func=open_patch),
- Component("Generate Template Settings", func=generate_yamls),
- Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
- Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
- Component("Browse Files", func=browse_files),
+ Component("Open host.yaml", func=open_host_yaml,
+ description="Open the host.yaml file to change settings for generation, games, and more."),
+ Component("Open Patch", func=open_patch,
+ description="Open a patch file, downloaded from the room page or provided by the host."),
+ Component("Generate Template Options", func=generate_yamls,
+ description="Generate template YAMLs for currently installed games."),
+ Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
+ description="Open archipelago.gg in your browser."),
+ Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
+ description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
+ Component("Unrated/18+ Discord Server", icon="discord",
+ func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
+ description="Find unrated and 18+ games in the After Dark Discord server."),
+ Component("Browse Files", func=browse_files,
+ description="Open the Archipelago installation folder in your file browser."),
])
-def identify(path: Union[None, str]):
+def handle_uri(path: str) -> tuple[list[Component], Component]:
+ url = urllib.parse.urlparse(path)
+ queries = urllib.parse.parse_qs(url.query)
+ client_components = []
+ text_client_component = None
+ game = queries["game"][0]
+ for component in components:
+ if component.supports_uri and component.game_name == game:
+ client_components.append(component)
+ elif component.display_name == "Text Client":
+ text_client_component = component
+ return client_components, text_client_component
+
+
+def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
+ from kvui import ButtonsPrompt
+ component_options = {
+ component.display_name: component for component in component_list
+ }
+ popup = ButtonsPrompt("Connect to Multiworld",
+ "Select client to open and connect with.",
+ lambda component_name: run_component(component_options[component_name], *launch_args),
+ *component_options.keys())
+ popup.open()
+
+
+def identify(path: None | str) -> tuple[None | str, None | Component]:
if path is None:
return None, None
for component in components:
if component.handles_file(path):
- return path, component
+ return path, component
elif path == component.display_name or path == component.script_name:
return None, component
return None, None
-def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
+def get_exe(component: str | Component) -> Sequence[str] | None:
if isinstance(component, str):
name = component
component = None
- if name.startswith('Archipelago'):
+ if name.startswith("Archipelago"):
name = name[11:]
- if name.endswith('.exe'):
+ if name.endswith(".exe"):
name = name[:-4]
- if name.endswith('.py'):
+ if name.endswith(".py"):
name = name[:-3]
if not name:
return None
for c in components:
- if c.script_name == name or c.frozen_name == f'Archipelago{name}':
+ if c.script_name == name or c.frozen_name == f"Archipelago{name}":
component = c
break
if not component:
return None
if is_frozen():
- suffix = '.exe' if is_windows else ''
- return [local_path(f'{component.frozen_name}{suffix}')]
+ suffix = ".exe" if is_windows else ""
+ return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None
else:
- return [sys.executable, local_path(f'{component.script_name}.py')]
+ return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None
def launch(exe, in_terminal=False):
if in_terminal:
if is_windows:
- subprocess.Popen(['start', *exe], shell=True)
+ # intentionally using a window title with a space so it gets quoted and treated as a title
+ subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
return
elif is_linux:
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
@@ -155,139 +216,302 @@ def launch(exe, in_terminal=False):
subprocess.Popen(exe)
-def run_gui():
- from kvui import App, ContainerLayout, GridLayout, Button, Label
- from kivy.uix.image import AsyncImage
- from kivy.uix.relativelayout import RelativeLayout
+def create_shortcut(button: Any, component: Component) -> None:
+ from pyshortcuts import make_shortcut
+ env = os.environ
+ if "APPIMAGE" in env:
+ script = env["ARGV0"]
+ wkdir = None # defaults to ~ on Linux
+ else:
+ script = sys.argv[0]
+ wkdir = Utils.local_path()
+
+ script = f"{script} \"{component.display_name}\""
+ make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"),
+ startmenu=False, terminal=False, working_dir=wkdir, noexe=Utils.is_frozen())
+ button.menu.dismiss()
+
+
+refresh_components: Callable[[], None] | None = None
- class Launcher(App):
- base_title: str = "Archipelago Launcher"
- container: ContainerLayout
- grid: GridLayout
- _tools = {c.display_name: c for c in components if c.type == Type.TOOL}
- _clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
- _adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
- _miscs = {c.display_name: c for c in components if c.type == Type.MISC}
+def run_gui(launch_components: list[Component], args: Any) -> None:
+ from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
+ from kivy.properties import ObjectProperty
+ from kivy.core.window import Window
+ from kivy.metrics import dp
+ from kivymd.uix.button import MDIconButton, MDButton
+ from kivymd.uix.card import MDCard
+ from kivymd.uix.menu import MDDropdownMenu
+ from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
+ from kivymd.uix.textfield import MDTextField
- def __init__(self, ctx=None):
- self.title = self.base_title
+ from kivy.lang.builder import Builder
+
+ class LauncherCard(MDCard):
+ component: Component | None
+ image: str
+ context_button: MDIconButton = ObjectProperty(None)
+
+ def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
+ self.component = component
+ self.image = image_path
+ super().__init__(args, kwargs)
+
+ class Launcher(ThemedApp):
+ base_title: str = "Archipelago Launcher"
+ top_screen: MDFloatLayout = ObjectProperty(None)
+ navigation: MDGridLayout = ObjectProperty(None)
+ grid: MDGridLayout = ObjectProperty(None)
+ button_layout: ScrollBox = ObjectProperty(None)
+ search_box: MDTextField = ObjectProperty(None)
+ cards: list[LauncherCard]
+ current_filter: Sequence[str | Type] | None
+
+ def __init__(self, ctx=None, components=None, args=None):
+ self.title = self.base_title + " " + Utils.__version__
self.ctx = ctx
self.icon = r"data/icon.png"
+ self.favorites = []
+ self.launch_components = components
+ self.launch_args = args
+ self.cards = []
+ self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
+ persistent = Utils.persistent_load()
+ if "launcher" in persistent:
+ if "favorites" in persistent["launcher"]:
+ self.favorites.extend(persistent["launcher"]["favorites"])
+ if "filter" in persistent["launcher"]:
+ if persistent["launcher"]["filter"]:
+ filters = []
+ for filter in persistent["launcher"]["filter"].split(", "):
+ if filter == "favorites":
+ filters.append(filter)
+ else:
+ filters.append(Type[filter])
+ self.current_filter = filters
super().__init__()
- def build(self):
- self.container = ContainerLayout()
- self.grid = GridLayout(cols=2)
- self.container.add_widget(self.grid)
- self.grid.add_widget(Label(text="General"))
- self.grid.add_widget(Label(text="Clients"))
- button_layout = self.grid # make buttons fill the window
-
- def build_button(component: Component):
- """
- Builds a button widget for a given component.
+ def set_favorite(self, caller):
+ if caller.component.display_name in self.favorites:
+ self.favorites.remove(caller.component.display_name)
+ caller.icon = "star-outline"
+ else:
+ self.favorites.append(caller.component.display_name)
+ caller.icon = "star"
- Args:
- component (Component): The component associated with the button.
+ def build_card(self, component: Component) -> LauncherCard:
+ """
+ Builds a card widget for a given component.
- Returns:
- None. The button is added to the parent grid layout.
+ :param component: The component associated with the button.
+ :return: The created Card Widget.
"""
- button = Button(text=component.display_name)
- button.component = component
- button.bind(on_release=self.component_action)
- if component.icon != "icon":
- image = AsyncImage(source=icon_paths[component.icon],
- size=(38, 38), size_hint=(None, 1), pos=(5, 0))
- box_layout = RelativeLayout()
- box_layout.add_widget(button)
- box_layout.add_widget(image)
- button_layout.add_widget(box_layout)
- else:
- button_layout.add_widget(button)
-
- for (tool, client) in itertools.zip_longest(itertools.chain(
- self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
- # column 1
- if tool:
- build_button(tool[1])
- else:
- button_layout.add_widget(Label())
- # column 2
- if client:
- build_button(client[1])
- else:
- button_layout.add_widget(Label())
-
- return self.container
+ button_card = LauncherCard(component=component,
+ image_path=icon_paths[component.icon])
+
+ def open_menu(caller):
+ caller.menu.open()
+
+ menu_items = [
+ {
+ "text": "Add shortcut on desktop",
+ "leading_icon": "laptop",
+ "on_release": lambda: create_shortcut(button_card.context_button, component)
+ }
+ ]
+ button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items)
+ button_card.context_button.bind(on_release=open_menu)
+
+ return button_card
+
+ def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
+ if not type_filter:
+ type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
+ favorites = "favorites" in type_filter
+
+ # clear before repopulating
+ assert self.button_layout, "must call `build` first"
+ tool_children = reversed(self.button_layout.layout.children)
+ for child in tool_children:
+ self.button_layout.layout.remove_widget(child)
+
+ cards = [card for card in self.cards if card.component.type in type_filter
+ or favorites and card.component.display_name in self.favorites]
+
+ self.current_filter = type_filter
+
+ for card in cards:
+ self.button_layout.layout.add_widget(card)
+
+ top = self.button_layout.children[0].y + self.button_layout.children[0].height \
+ - self.button_layout.height
+ scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
+ self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
+
+ def filter_clients_by_type(self, caller: MDButton):
+ self._refresh_components(caller.type)
+ self.search_box.text = ""
+
+ def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
+ if len(name) == 0:
+ self._refresh_components(self.current_filter)
+ return
+
+ sub_matches = [
+ card for card in self.cards
+ if name.lower() in card.component.display_name.lower() and card.component.type != Type.HIDDEN
+ ]
+ self.button_layout.layout.clear_widgets()
+ for card in sub_matches:
+ self.button_layout.layout.add_widget(card)
+
+ def build(self):
+ self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
+ self.grid = self.top_screen.ids.grid
+ self.navigation = self.top_screen.ids.navigation
+ self.button_layout = self.top_screen.ids.button_layout
+ self.search_box = self.top_screen.ids.search_box
+ self.set_colors()
+ self.top_screen.md_bg_color = self.theme_cls.backgroundColor
+
+ global refresh_components
+ refresh_components = self._refresh_components
+
+ Window.bind(on_drop_file=self._on_drop_file)
+ Window.bind(on_keyboard=self._on_keyboard)
+
+ for component in components:
+ self.cards.append(self.build_card(component))
+
+ self._refresh_components(self.current_filter)
+
+ # Uncomment to re-enable the Kivy console/live editor
+ # Ctrl-E to enable it, make sure numlock/capslock is disabled
+ # from kivy.modules.console import create_console
+ # create_console(Window, self.top_screen)
+
+ return self.top_screen
+
+ def on_start(self):
+ if self.launch_components:
+ build_uri_popup(self.launch_components, self.launch_args)
+ self.launch_components = None
+ self.launch_args = None
@staticmethod
def component_action(button):
+ MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
+ size_hint_x=0.5).open()
if button.component.func:
button.component.func()
else:
launch(get_exe(button.component), button.component.cli)
+ def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
+ """ When a patch file is dropped into the window, run the associated component. """
+ file, component = identify(filename.decode())
+ if file and component:
+ run_component(component, file)
+ else:
+ logging.warning(f"unable to identify component for {filename}")
+
+ def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
+ # Activate search as soon as we start typing, no matter if we are focused on the search box or not.
+ # Focus first, then capture the first character we type, otherwise it gets swallowed and lost.
+ # Limit text input to ASCII non-control characters (space bar to tilde).
+ if not self.search_box.focus:
+ self.search_box.focus = True
+ if key in range(32, 126):
+ self.search_box.text += codepoint
+
def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
# Closing the window explicitly cleans it up.
self.root_window.close()
super()._stop(*largs)
- Launcher().run()
+ def on_stop(self):
+ Utils.persistent_store("launcher", "favorites", self.favorites)
+ Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
+ for filter in self.current_filter))
+ super().on_stop()
+
+ Launcher(components=launch_components, args=args).run()
+
+ # avoiding Launcher reference leak
+ # and don't try to do something with widgets after window closed
+ global refresh_components
+ refresh_components = None
def run_component(component: Component, *args):
if component.func:
component.func(*args)
+ if refresh_components:
+ refresh_components()
elif component.script_name:
subprocess.run([*get_exe(component.script_name), *args])
else:
logging.warning(f"Component {component} does not appear to be executable.")
-def main(args: Optional[Union[argparse.Namespace, dict]] = None):
+def main(args: argparse.Namespace | dict | None = None):
if isinstance(args, argparse.Namespace):
args = {k: v for k, v in args._get_kwargs()}
elif not args:
args = {}
- if "Patch|Game|Component" in args:
- file, component = identify(args["Patch|Game|Component"])
- if file:
- args['file'] = file
- if component:
- args['component'] = component
- if not component:
- logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}")
+ path = args.get("Patch|Game|Component|url", None)
+ if path is not None:
+ if path.startswith("archipelago://"):
+ args["args"] = (path, *args.get("args", ()))
+ # add the url arg to the passthrough args
+ components, text_client_component = handle_uri(path)
+ if not components:
+ args["component"] = text_client_component
+ else:
+ args['launch_components'] = [text_client_component, *components]
+ else:
+ file, component = identify(path)
+ if file:
+ args['file'] = file
+ if component:
+ args['component'] = component
+ if not component:
+ logging.warning(f"Could not identify Component responsible for {path}")
if args["update_settings"]:
update_settings()
- if 'file' in args:
+ if "file" in args:
run_component(args["component"], args["file"], *args["args"])
- elif 'component' in args:
+ elif "component" in args:
run_component(args["component"], *args["args"])
elif not args["update_settings"]:
- run_gui()
+ run_gui(args.get("launch_components", None), args.get("args", ()))
if __name__ == '__main__':
init_logging('Launcher')
- Utils.freeze_support()
+ multiprocessing.freeze_support()
multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work
- parser = argparse.ArgumentParser(description='Archipelago Launcher')
+ parser = argparse.ArgumentParser(
+ description='Archipelago Launcher',
+ usage="[-h] [--update_settings] [Patch|Game|Component] [-- component args here]"
+ )
run_group = parser.add_argument_group("Run")
run_group.add_argument("--update_settings", action="store_true",
help="Update host.yaml and exit.")
- run_group.add_argument("Patch|Game|Component", type=str, nargs="?",
- help="Pass either a patch file, a generated game or the name of a component to run.")
+ run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?",
+ help="Pass either a patch file, a generated game, the component name to run, or a url to "
+ "connect with.")
run_group.add_argument("args", nargs="*",
help="Arguments to pass to component.")
main(parser.parse_args())
from worlds.LauncherComponents import processes
+
for process in processes:
# we await all child processes to close before we tear down the process host
# this makes it feel like each one is its own program, as the Launcher is closed now
diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py
deleted file mode 100644
index f3fc9d2cdb72..000000000000
--- a/LinksAwakeningClient.py
+++ /dev/null
@@ -1,700 +0,0 @@
-import ModuleUpdate
-ModuleUpdate.update()
-
-import Utils
-
-if __name__ == "__main__":
- Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
-
-import asyncio
-import base64
-import binascii
-import colorama
-import io
-import os
-import re
-import select
-import shlex
-import socket
-import struct
-import sys
-import subprocess
-import time
-import typing
-
-
-from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
- server_loop)
-from NetUtils import ClientStatus
-from worlds.ladx.Common import BASE_ID as LABaseID
-from worlds.ladx.GpsTracker import GpsTracker
-from worlds.ladx.ItemTracker import ItemTracker
-from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
-from worlds.ladx.Locations import get_locations_to_id, meta_to_name
-from worlds.ladx.Tracker import LocationTracker, MagpieBridge
-
-
-class GameboyException(Exception):
- pass
-
-
-class RetroArchDisconnectError(GameboyException):
- pass
-
-
-class InvalidEmulatorStateError(GameboyException):
- pass
-
-
-class BadRetroArchResponse(GameboyException):
- pass
-
-
-def magpie_logo():
- from kivy.uix.image import CoreImage
- binary_data = """
-iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
-SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
-7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
-MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
-wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
-eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
-ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
-XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
- binary_data = base64.b64decode(binary_data)
- data = io.BytesIO(binary_data)
- return CoreImage(data, ext="png").texture
-
-
-class LAClientConstants:
- # Connector version
- VERSION = 0x01
- #
- # Memory locations of LADXR
- ROMGameID = 0x0051 # 4 bytes
- SlotName = 0x0134
- # Unused
- # ROMWorldID = 0x0055
- # ROMConnectorVersion = 0x0056
- # RO: We should only act if this is higher then 6, as it indicates that the game is running normally
- wGameplayType = 0xDB95
- # RO: Starts at 0, increases every time an item is received from the server and processed
- wLinkSyncSequenceNumber = 0xDDF6
- wLinkStatusBits = 0xDDF7 # RW:
- # Bit0: wLinkGive* contains valid data, set from script cleared from ROM.
- wLinkHealth = 0xDB5A
- wLinkGiveItem = 0xDDF8 # RW
- wLinkGiveItemFrom = 0xDDF9 # RW
- # All of these six bytes are unused, we can repurpose
- # wLinkSendItemRoomHigh = 0xDDFA # RO
- # wLinkSendItemRoomLow = 0xDDFB # RO
- # wLinkSendItemTarget = 0xDDFC # RO
- # wLinkSendItemItem = 0xDDFD # RO
- # wLinkSendShopItem = 0xDDFE # RO, which item to send (1 based, order of the shop items)
- # RO, which player to send to, but it's just the X position of the NPC used, so 0x18 is player 0
- # wLinkSendShopTarget = 0xDDFF
-
-
- wRecvIndex = 0xDDFD # Two bytes
- wCheckAddress = 0xC0FF - 0x4
- WRamCheckSize = 0x4
- WRamSafetyValue = bytearray([0]*WRamCheckSize)
-
- MinGameplayValue = 0x06
- MaxGameplayValue = 0x1A
- VictoryGameplayAndSub = 0x0102
-
-
-class RAGameboy():
- cache = []
- cache_start = 0
- cache_size = 0
- last_cache_read = None
- socket = None
-
- def __init__(self, address, port) -> None:
- self.address = address
- self.port = port
- self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- assert (self.socket)
- self.socket.setblocking(False)
-
- async def send_command(self, command, timeout=1.0):
- self.send(f'{command}\n')
- response_str = await self.async_recv()
- self.check_command_response(command, response_str)
- return response_str.rstrip()
-
- async def get_retroarch_version(self):
- return await self.send_command("VERSION")
-
- async def get_retroarch_status(self):
- return await self.send_command("GET_STATUS")
-
- def set_cache_limits(self, cache_start, cache_size):
- self.cache_start = cache_start
- self.cache_size = cache_size
-
- def send(self, b):
- if type(b) is str:
- b = b.encode('ascii')
- self.socket.sendto(b, (self.address, self.port))
-
- def recv(self):
- select.select([self.socket], [], [])
- response, _ = self.socket.recvfrom(4096)
- return response
-
- async def async_recv(self, timeout=1.0):
- response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), timeout)
- return response
-
- async def check_safe_gameplay(self, throw=True):
- async def check_wram():
- check_values = await self.async_read_memory(LAClientConstants.wCheckAddress, LAClientConstants.WRamCheckSize)
-
- if check_values != LAClientConstants.WRamSafetyValue:
- if throw:
- raise InvalidEmulatorStateError()
- return False
- return True
-
- if not await check_wram():
- if throw:
- raise InvalidEmulatorStateError()
- return False
-
- gameplay_value = await self.async_read_memory(LAClientConstants.wGameplayType)
- gameplay_value = gameplay_value[0]
- # In gameplay or credits
- if not (LAClientConstants.MinGameplayValue <= gameplay_value <= LAClientConstants.MaxGameplayValue) and gameplay_value != 0x1:
- if throw:
- logger.info("invalid emu state")
- raise InvalidEmulatorStateError()
- return False
- if not await check_wram():
- if throw:
- raise InvalidEmulatorStateError()
- return False
- return True
-
- # We're sadly unable to update the whole cache at once
- # as RetroArch only gives back some number of bytes at a time
- # So instead read as big as chunks at a time as we can manage
- async def update_cache(self):
- # First read the safety address - if it's invalid, bail
- self.cache = []
-
- if not await self.check_safe_gameplay():
- return
-
- cache = []
- remaining_size = self.cache_size
- while remaining_size:
- block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
- remaining_size -= len(block)
- cache += block
-
- if not await self.check_safe_gameplay():
- return
-
- self.cache = cache
- self.last_cache_read = time.time()
-
- async def read_memory_cache(self, addresses):
- # TODO: can we just update once per frame?
- if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
- await self.update_cache()
- if not self.cache:
- return None
- assert (len(self.cache) == self.cache_size)
- for address in addresses:
- assert self.cache_start <= address <= self.cache_start + self.cache_size
- r = {address: self.cache[address - self.cache_start]
- for address in addresses}
- return r
-
- async def async_read_memory_safe(self, address, size=1):
- # whenever we do a read for a check, we need to make sure that we aren't reading
- # garbage memory values - we also need to protect against reading a value, then the emulator resetting
- #
- # ...actually, we probably _only_ need the post check
-
- # Check before read
- if not await self.check_safe_gameplay():
- return None
-
- # Do read
- r = await self.async_read_memory(address, size)
-
- # Check after read
- if not await self.check_safe_gameplay():
- return None
-
- return r
-
- def check_command_response(self, command: str, response: bytes):
- if command == "VERSION":
- ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None
- else:
- ok = response.startswith(command.encode())
- if not ok:
- logger.warning(f"Bad response to command {command} - {response}")
- raise BadRetroArchResponse()
-
- def read_memory(self, address, size=1):
- command = "READ_CORE_MEMORY"
-
- self.send(f'{command} {hex(address)} {size}\n')
- response = self.recv()
-
- self.check_command_response(command, response)
-
- splits = response.decode().split(" ", 2)
- # Ignore the address for now
- if splits[2][:2] == "-1":
- raise BadRetroArchResponse()
-
- # TODO: check response address, check hex behavior between RA and BH
-
- return bytearray.fromhex(splits[2])
-
- async def async_read_memory(self, address, size=1):
- command = "READ_CORE_MEMORY"
-
- self.send(f'{command} {hex(address)} {size}\n')
- response = await self.async_recv()
- self.check_command_response(command, response)
- response = response[:-1]
- splits = response.decode().split(" ", 2)
- try:
- response_addr = int(splits[1], 16)
- except ValueError:
- raise BadRetroArchResponse()
-
- if response_addr != address:
- raise BadRetroArchResponse()
-
- ret = bytearray.fromhex(splits[2])
- if len(ret) > size:
- raise BadRetroArchResponse()
- return ret
-
- def write_memory(self, address, bytes):
- command = "WRITE_CORE_MEMORY"
-
- self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}')
- select.select([self.socket], [], [])
- response, _ = self.socket.recvfrom(4096)
- self.check_command_response(command, response)
- splits = response.decode().split(" ", 3)
-
- assert (splits[0] == command)
-
- if splits[2] == "-1":
- logger.info(splits[3])
-
-
-class LinksAwakeningClient():
- socket = None
- gameboy = None
- tracker = None
- auth = None
- game_crc = None
- pending_deathlink = False
- deathlink_debounce = True
- recvd_checks = {}
- retroarch_address = None
- retroarch_port = None
- gameboy = None
-
- def msg(self, m):
- logger.info(m)
- s = f"SHOW_MSG {m}\n"
- self.gameboy.send(s)
-
- def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355):
- self.retroarch_address = retroarch_address
- self.retroarch_port = retroarch_port
- pass
-
- stop_bizhawk_spam = False
- async def wait_for_retroarch_connection(self):
- if not self.stop_bizhawk_spam:
- logger.info("Waiting on connection to Retroarch...")
- self.stop_bizhawk_spam = True
- self.gameboy = RAGameboy(self.retroarch_address, self.retroarch_port)
-
- while True:
- try:
- version = await self.gameboy.get_retroarch_version()
- NO_CONTENT = b"GET_STATUS CONTENTLESS"
- status = NO_CONTENT
- core_type = None
- GAME_BOY = b"game_boy"
- while status == NO_CONTENT or core_type != GAME_BOY:
- status = await self.gameboy.get_retroarch_status()
- if status.count(b" ") < 2:
- await asyncio.sleep(1.0)
- continue
- GET_STATUS, PLAYING, info = status.split(b" ", 2)
- if status.count(b",") < 2:
- await asyncio.sleep(1.0)
- continue
- core_type, rom_name, self.game_crc = info.split(b",", 2)
- if core_type != GAME_BOY:
- logger.info(
- f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?")
- await asyncio.sleep(1.0)
- continue
- self.stop_bizhawk_spam = False
- logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}")
- return
- except (BlockingIOError, TimeoutError, ConnectionResetError):
- await asyncio.sleep(1.0)
- pass
-
- async def reset_auth(self):
- auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
- self.auth = auth
-
- async def wait_and_init_tracker(self):
- await self.wait_for_game_ready()
- self.tracker = LocationTracker(self.gameboy)
- self.item_tracker = ItemTracker(self.gameboy)
- self.gps_tracker = GpsTracker(self.gameboy)
-
- async def recved_item_from_ap(self, item_id, from_player, next_index):
- # Don't allow getting an item until you've got your first check
- if not self.tracker.has_start_item():
- return
-
- # Spin until we either:
- # get an exception from a bad read (emu shut down or reset)
- # beat the game
- # the client handles the last pending item
- status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
- while not (await self.is_victory()) and status & 1 == 1:
- time.sleep(0.1)
- status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
-
- item_id -= LABaseID
- # The player name table only goes up to 100, so don't go past that
- # Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
- if from_player > 100:
- from_player = 100
-
- next_index += 1
- self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
- item_id, from_player])
- status |= 1
- status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status])
- self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index))
-
- should_reset_auth = False
- async def wait_for_game_ready(self):
- logger.info("Waiting on game to be in valid state...")
- while not await self.gameboy.check_safe_gameplay(throw=False):
- if self.should_reset_auth:
- self.should_reset_auth = False
- raise GameboyException("Resetting due to wrong archipelago server")
- logger.info("Game connection ready!")
-
- async def is_victory(self):
- return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
-
- async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
- await self.tracker.readChecks(item_get_cb)
- await self.item_tracker.readItems()
- await self.gps_tracker.read_location()
-
- current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
- if self.deathlink_debounce and current_health != 0:
- self.deathlink_debounce = False
- elif not self.deathlink_debounce and current_health == 0:
- # logger.info("YOU DIED.")
- await deathlink_cb()
- self.deathlink_debounce = True
-
- if self.pending_deathlink:
- logger.info("Got a deathlink")
- self.gameboy.write_memory(LAClientConstants.wLinkHealth, [0])
- self.pending_deathlink = False
- self.deathlink_debounce = True
-
- if await self.is_victory():
- await win_cb()
-
- recv_index = struct.unpack(">H", await self.gameboy.async_read_memory(LAClientConstants.wRecvIndex, 2))[0]
-
- # Play back one at a time
- if recv_index in self.recvd_checks:
- item = self.recvd_checks[recv_index]
- await self.recved_item_from_ap(item.item, item.player, recv_index)
-
-
-all_tasks = set()
-
-def create_task_log_exception(awaitable) -> asyncio.Task:
- async def _log_exception(awaitable):
- try:
- return await awaitable
- except Exception as e:
- logger.exception(e)
- pass
- finally:
- all_tasks.remove(task)
- task = asyncio.create_task(_log_exception(awaitable))
- all_tasks.add(task)
-
-
-class LinksAwakeningContext(CommonContext):
- tags = {"AP"}
- game = "Links Awakening DX"
- items_handling = 0b101
- want_slot_data = True
- la_task = None
- client = None
- # TODO: does this need to re-read on reset?
- found_checks = []
- last_resend = time.time()
-
- magpie_enabled = False
- magpie = None
- magpie_task = None
- won = False
-
- def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
- self.client = LinksAwakeningClient()
- if magpie:
- self.magpie_enabled = True
- self.magpie = MagpieBridge()
- super().__init__(server_address, password)
-
- def run_gui(self) -> None:
- import webbrowser
- import kvui
- from kvui import Button, GameManager
- from kivy.uix.image import Image
-
- class LADXManager(GameManager):
- logging_pairs = [
- ("Client", "Archipelago"),
- ("Tracker", "Tracker"),
- ]
- base_title = "Archipelago Links Awakening DX Client"
-
- def build(self):
- b = super().build()
-
- if self.ctx.magpie_enabled:
- button = Button(text="", size=(30, 30), size_hint_x=None,
- on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
- image = Image(size=(16, 16), texture=magpie_logo())
- button.add_widget(image)
-
- def set_center(_, center):
- image.center = center
- button.bind(center=set_center)
-
- self.connect_layout.add_widget(button)
- return b
-
- self.ui = LADXManager(self)
- self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
-
- async def send_checks(self):
- message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
- await self.send_msgs(message)
-
- had_invalid_slot_data = None
- def event_invalid_slot(self):
- # The next time we try to connect, reset the game loop for new auth
- self.had_invalid_slot_data = True
- self.auth = None
- # Don't try to autoreconnect, it will just fail
- self.disconnected_intentionally = True
- CommonContext.event_invalid_slot(self)
-
- ENABLE_DEATHLINK = False
- async def send_deathlink(self):
- if self.ENABLE_DEATHLINK:
- message = [{"cmd": 'Deathlink',
- 'time': time.time(),
- 'cause': 'Had a nightmare',
- # 'source': self.slot_info[self.slot].name,
- }]
- await self.send_msgs(message)
-
- async def send_victory(self):
- if not self.won:
- message = [{"cmd": "StatusUpdate",
- "status": ClientStatus.CLIENT_GOAL}]
- logger.info("victory!")
- await self.send_msgs(message)
- self.won = True
-
- async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
- if self.ENABLE_DEATHLINK:
- self.client.pending_deathlink = True
-
- def new_checks(self, item_ids, ladxr_ids):
- self.found_checks += item_ids
- create_task_log_exception(self.send_checks())
- if self.magpie_enabled:
- create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
-
- async def server_auth(self, password_requested: bool = False):
- if password_requested and not self.password:
- await super(LinksAwakeningContext, self).server_auth(password_requested)
-
- if self.had_invalid_slot_data:
- # We are connecting when previously we had the wrong ROM or server - just in case
- # re-read the ROM so that if the user had the correct address but wrong ROM, we
- # allow a successful reconnect
- self.client.should_reset_auth = True
- self.had_invalid_slot_data = False
-
- while self.client.auth == None:
- await asyncio.sleep(0.1)
- self.auth = self.client.auth
- await self.send_connect()
-
- def on_package(self, cmd: str, args: dict):
- if cmd == "Connected":
- self.game = self.slot_info[self.slot].game
- # TODO - use watcher_event
- if cmd == "ReceivedItems":
- for index, item in enumerate(args["items"], start=args["index"]):
- self.client.recvd_checks[index] = item
-
- async def sync(self):
- sync_msg = [{'cmd': 'Sync'}]
- await self.send_msgs(sync_msg)
-
- item_id_lookup = get_locations_to_id()
-
- async def run_game_loop(self):
- def on_item_get(ladxr_checks):
- checks = [self.item_id_lookup[meta_to_name(
- checkMetadataTable[check.id])] for check in ladxr_checks]
- self.new_checks(checks, [check.id for check in ladxr_checks])
-
- async def victory():
- await self.send_victory()
-
- async def deathlink():
- await self.send_deathlink()
-
- if self.magpie_enabled:
- self.magpie_task = asyncio.create_task(self.magpie.serve())
-
- # yield to allow UI to start
- await asyncio.sleep(0)
-
- while True:
- try:
- # TODO: cancel all client tasks
- if not self.client.stop_bizhawk_spam:
- logger.info("(Re)Starting game loop")
- self.found_checks.clear()
- # On restart of game loop, clear all checks, just in case we swapped ROMs
- # this isn't totally neccessary, but is extra safety against cross-ROM contamination
- self.client.recvd_checks.clear()
- await self.client.wait_for_retroarch_connection()
- await self.client.reset_auth()
- # If we find ourselves with new auth after the reset, reconnect
- if self.auth and self.client.auth != self.auth:
- # It would be neat to reconnect here, but connection needs this loop to be running
- logger.info("Detected new ROM, disconnecting...")
- await self.disconnect()
- continue
-
- if not self.client.recvd_checks:
- await self.sync()
-
- await self.client.wait_and_init_tracker()
-
- while True:
- await self.client.main_tick(on_item_get, victory, deathlink)
- await asyncio.sleep(0.1)
- now = time.time()
- if self.last_resend + 5.0 < now:
- self.last_resend = now
- await self.send_checks()
- if self.magpie_enabled:
- try:
- self.magpie.set_checks(self.client.tracker.all_checks)
- await self.magpie.set_item_tracker(self.client.item_tracker)
- await self.magpie.send_gps(self.client.gps_tracker)
- except Exception:
- # Don't let magpie errors take out the client
- pass
- if self.client.should_reset_auth:
- self.client.should_reset_auth = False
- raise GameboyException("Resetting due to wrong archipelago server")
- except (GameboyException, asyncio.TimeoutError, TimeoutError, ConnectionResetError):
- await asyncio.sleep(1.0)
-
-def run_game(romfile: str) -> None:
- auto_start = typing.cast(typing.Union[bool, str],
- Utils.get_options()["ladx_options"].get("rom_start", True))
- if auto_start is True:
- import webbrowser
- webbrowser.open(romfile)
- elif isinstance(auto_start, str):
- args = shlex.split(auto_start)
- # Specify full path to ROM as we are going to cd in popen
- full_rom_path = os.path.realpath(romfile)
- args.append(full_rom_path)
- try:
- # set cwd so that paths to lua scripts are always relative to our client
- if getattr(sys, 'frozen', False):
- # The application is frozen
- script_dir = os.path.dirname(sys.executable)
- else:
- script_dir = os.path.dirname(os.path.realpath(__file__))
-
- subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=script_dir)
- except FileNotFoundError:
- logger.error(f"Couldn't launch ROM, {args[0]} is missing")
-
-async def main():
- parser = get_base_parser(description="Link's Awakening Client.")
- parser.add_argument("--url", help="Archipelago connection url")
- parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
- parser.add_argument('diff_file', default="", type=str, nargs="?",
- help='Path to a .apladx Archipelago Binary Patch file')
-
- args = parser.parse_args()
-
- if args.diff_file:
- import Patch
- logger.info("patch file was supplied - creating rom...")
- meta, rom_file = Patch.create_rom_file(args.diff_file)
- if "server" in meta and not args.connect:
- args.connect = meta["server"]
- logger.info(f"wrote rom file to {rom_file}")
-
-
- ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
-
- ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
-
- # TODO: nothing about the lambda about has to be in a lambda
- ctx.la_task = create_task_log_exception(ctx.run_game_loop())
- if gui_enabled:
- ctx.run_gui()
- ctx.run_cli()
-
- # Down below run_gui so that we get errors out of the process
- if args.diff_file:
- run_game(rom_file)
-
- await ctx.exit_event.wait()
- await ctx.shutdown()
-
-if __name__ == '__main__':
- colorama.init()
- asyncio.run(main())
- colorama.deinit()
diff --git a/LttPAdjuster.py b/LttPAdjuster.py
index d1c03bd49eeb..4816210ff5be 100644
--- a/LttPAdjuster.py
+++ b/LttPAdjuster.py
@@ -14,7 +14,7 @@
from argparse import Namespace
from concurrent.futures import as_completed, ThreadPoolExecutor
from glob import glob
-from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, TOP, LabelFrame, \
+from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, Radiobutton, LEFT, X, BOTH, TOP, LabelFrame, \
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
from tkinter.constants import DISABLED, NORMAL
from urllib.parse import urlparse
@@ -25,17 +25,23 @@
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
- get_adjuster_settings, tkinter_center_window, init_logging
+ get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging
GAME_ALTTP = "A Link to the Past"
+WINDOW_MIN_HEIGHT = 525
+WINDOW_MIN_WIDTH = 425
class AdjusterWorld(object):
+ class AdjusterSubWorld(object):
+ def __init__(self, random):
+ self.random = random
+
def __init__(self, sprite_pool):
import random
self.sprite_pool = {1: sprite_pool}
- self.per_slot_randoms = {1: random}
+ self.worlds = {1: self.AdjusterSubWorld(random)}
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
@@ -44,6 +50,48 @@ def _get_help_string(self, action):
return textwrap.dedent(action.help)
+# See argparse.BooleanOptionalAction
+class BooleanOptionalActionWithDisable(argparse.Action):
+ def __init__(self,
+ option_strings,
+ dest,
+ default=None,
+ type=None,
+ choices=None,
+ required=False,
+ help=None,
+ metavar=None):
+
+ _option_strings = []
+ for option_string in option_strings:
+ _option_strings.append(option_string)
+
+ if option_string.startswith('--'):
+ option_string = '--disable' + option_string[2:]
+ _option_strings.append(option_string)
+
+ if help is not None and default is not None:
+ help += " (default: %(default)s)"
+
+ super().__init__(
+ option_strings=_option_strings,
+ dest=dest,
+ nargs=0,
+ default=default,
+ type=type,
+ choices=choices,
+ required=required,
+ help=help,
+ metavar=metavar)
+
+ def __call__(self, parser, namespace, values, option_string=None):
+ if option_string in self.option_strings:
+ setattr(namespace, self.dest, not option_string.startswith('--disable'))
+
+ def format_usage(self):
+ return ' | '.join(self.option_strings)
+
+
def get_argparser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
@@ -52,6 +100,8 @@ def get_argparser() -> argparse.ArgumentParser:
help='Path to an ALttP Japan(1.0) rom to use as a base.')
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
+ parser.add_argument('--auto_apply', default='ask',
+ choices=['ask', 'always', 'never'], help='Whether or not to apply settings automatically in the future.')
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'],
help='''\
@@ -61,7 +111,7 @@ def get_argparser() -> argparse.ArgumentParser:
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true')
- parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
+ parser.add_argument('--music', default=True, help='Enables/Disables game music.', action=BooleanOptionalActionWithDisable)
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
help='''\
@@ -104,21 +154,23 @@ def get_argparser() -> argparse.ArgumentParser:
Alternatively, can be a ALttP Rom patched with a Link
sprite that will be extracted.
''')
+ parser.add_argument('--sprite_pool', nargs='+', default=[], help='''
+ A list of sprites to pull from.
+ ''')
parser.add_argument('--oof', help='''\
Path to a sound effect to replace Link's "oof" sound.
Needs to be in a .brr format and have a length of no
more than 2673 bytes, created from a 16-bit signed PCM
.wav at 12khz. https://github.com/boldowa/snesbrr
''')
- parser.add_argument('--names', default='', type=str)
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
return parser
def main():
parser = get_argparser()
- args = parser.parse_args()
- args.music = not args.disablemusic
+ args = parser.parse_args(namespace=get_adjuster_settings_no_defaults(GAME_ALTTP))
+
# set up logger
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
args.loglevel]
@@ -197,16 +249,17 @@ def adjustGUI():
from argparse import Namespace
from Utils import __version__ as MWVersion
adjustWindow = Tk()
+ adjustWindow.minsize(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion)
set_icon(adjustWindow)
rom_options_frame, rom_vars, set_sprite = get_rom_options_frame(adjustWindow)
- bottomFrame2 = Frame(adjustWindow)
+ bottomFrame2 = Frame(adjustWindow, padx=8, pady=2)
romFrame, romVar = get_rom_frame(adjustWindow)
- romDialogFrame = Frame(adjustWindow)
+ romDialogFrame = Frame(adjustWindow, padx=8, pady=2)
baseRomLabel2 = Label(romDialogFrame, text='Rom to adjust')
romVar2 = StringVar()
romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
@@ -216,9 +269,9 @@ def RomSelect2():
romVar2.set(rom)
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
- romDialogFrame.pack(side=TOP, expand=True, fill=X)
- baseRomLabel2.pack(side=LEFT)
- romEntry2.pack(side=LEFT, expand=True, fill=X)
+ romDialogFrame.pack(side=TOP, expand=False, fill=X)
+ baseRomLabel2.pack(side=LEFT, expand=False, fill=X, padx=(0, 8))
+ romEntry2.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
romSelectButton2.pack(side=LEFT)
def adjustRom():
@@ -286,12 +339,11 @@ def saveGUISettings():
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
adjustButton = Button(bottomFrame2, text='Adjust Rom', command=adjustRom)
- rom_options_frame.pack(side=TOP)
+ rom_options_frame.pack(side=TOP, padx=8, pady=8, fill=BOTH, expand=True)
adjustButton.pack(side=LEFT, padx=(5,5))
saveButton = Button(bottomFrame2, text='Save Settings', command=saveGUISettings)
saveButton.pack(side=LEFT, padx=(5,5))
-
bottomFrame2.pack(side=TOP, pady=(5,5))
tkinter_center_window(adjustWindow)
@@ -313,10 +365,10 @@ def run_sprite_update():
logging.info("Done updating sprites")
-def update_sprites(task, on_finish=None):
+def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"):
resultmessage = ""
successful = True
- sprite_dir = user_path("data", "sprites", "alttpr")
+ sprite_dir = user_path("data", "sprites", "alttp", "remote")
os.makedirs(sprite_dir, exist_ok=True)
ctx = get_cert_none_ssl_context()
@@ -326,11 +378,11 @@ def finished():
on_finish(successful, resultmessage)
try:
- task.update_status("Downloading alttpr sprites list")
- with urlopen('https://alttpr.com/sprites', context=ctx) as response:
+ task.update_status("Downloading remote sprites list")
+ with urlopen(repository_url, context=ctx) as response:
sprites_arr = json.loads(response.read().decode("utf-8"))
except Exception as e:
- resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
+ resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
successful = False
task.queue_event(finished)
return
@@ -338,13 +390,13 @@ def finished():
try:
task.update_status("Determining needed sprites")
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
- alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
+ remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
- needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
+ needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if
filename not in current_sprites]
- alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
- obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
+ remote_filenames = [filename for (_, filename) in remote_sprites]
+ obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames]
except Exception as e:
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
type(e).__name__, e)
@@ -396,7 +448,7 @@ def rem(sprite):
successful = False
if successful:
- resultmessage = "alttpr sprites updated successfully"
+ resultmessage = "Remote sprites updated successfully"
task.queue_event(finished)
@@ -530,11 +582,8 @@ def hide(self):
def get_rom_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
- if not adjuster_settings:
- adjuster_settings = Namespace()
- adjuster_settings.baserom = "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
- romFrame = Frame(parent)
+ romFrame = Frame(parent, padx=8, pady=8)
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
romVar = StringVar(value=adjuster_settings.baserom)
romEntry = Entry(romFrame, textvariable=romVar)
@@ -554,45 +603,19 @@ def RomSelect():
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
baseRomLabel.pack(side=LEFT)
- romEntry.pack(side=LEFT, expand=True, fill=X)
+ romEntry.pack(side=LEFT, expand=True, fill=BOTH, pady=1)
romSelectButton.pack(side=LEFT)
- romFrame.pack(side=TOP, expand=True, fill=X)
+ romFrame.pack(side=TOP, fill=X)
return romFrame, romVar
-
def get_rom_options_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
- defaults = {
- "auto_apply": 'ask',
- "music": True,
- "reduceflashing": True,
- "deathlink": False,
- "sprite": None,
- "oof": None,
- "quickswap": True,
- "menuspeed": 'normal',
- "heartcolor": 'red',
- "heartbeep": 'normal',
- "ow_palettes": 'default',
- "uw_palettes": 'default',
- "hud_palettes": 'default',
- "sword_palettes": 'default',
- "shield_palettes": 'default',
- "sprite_pool": [],
- "allowcollect": False,
- }
- if not adjuster_settings:
- adjuster_settings = Namespace()
- for key, defaultvalue in defaults.items():
- if not hasattr(adjuster_settings, key):
- setattr(adjuster_settings, key, defaultvalue)
-
- romOptionsFrame = LabelFrame(parent, text="Rom options")
- romOptionsFrame.columnconfigure(0, weight=1)
- romOptionsFrame.columnconfigure(1, weight=1)
+
+ romOptionsFrame = LabelFrame(parent, text="Rom options", padx=8, pady=8)
+
for i in range(5):
- romOptionsFrame.rowconfigure(i, weight=1)
+ romOptionsFrame.rowconfigure(i, weight=0, pad=4)
vars = Namespace()
vars.MusicVar = IntVar()
@@ -643,7 +666,7 @@ def SpriteSelect():
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
baseSpriteLabel.pack(side=LEFT)
- spriteEntry.pack(side=LEFT)
+ spriteEntry.pack(side=LEFT, expand=True, fill=X)
spriteSelectButton.pack(side=LEFT)
oofDialogFrame = Frame(romOptionsFrame)
@@ -846,7 +869,7 @@ def __init__(self, parent, callback, adjuster=False, randomOnEvent=True, spriteP
def open_custom_sprite_dir(_evt):
open_file(self.custom_sprite_dir)
- alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
+ remote_frametitle = Label(self.window, text='Remote Sprites')
custom_frametitle = Frame(self.window)
title_text = Label(custom_frametitle, text="Custom Sprites")
@@ -855,8 +878,8 @@ def open_custom_sprite_dir(_evt):
title_link.pack(side=LEFT)
title_link.bind("", open_custom_sprite_dir)
- self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
- 'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
+ self.icon_section(remote_frametitle, self.remote_sprite_dir,
+ 'Remote sprites not found. Click "Update remote sprites" to download them.')
self.icon_section(custom_frametitle, self.custom_sprite_dir,
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
if not randomOnEvent:
@@ -869,11 +892,18 @@ def open_custom_sprite_dir(_evt):
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
button.pack(side=RIGHT, padx=(5, 0))
- button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
+ button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites)
button.pack(side=RIGHT, padx=(5, 0))
+
+ repository_label = Label(frame, text='Sprite Repository:')
+ self.repository_url = StringVar(frame, "https://alttpr.com/sprites")
+ repository_entry = Entry(frame, textvariable=self.repository_url)
+ repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1)
+ repository_label.pack(side=RIGHT, expand=False, padx=(0, 5))
+
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
- button.pack(side=LEFT,padx=(0,5))
+ button.pack(side=LEFT, padx=(0, 5))
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
button.pack(side=LEFT, padx=(0, 5))
@@ -987,6 +1017,7 @@ def update_sprites(event):
self.add_to_sprite_pool(sprite)
def icon_section(self, frame_label, path, no_results_label):
+ os.makedirs(path, exist_ok=True)
frame = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5)
frame.pack(side=TOP, fill=X)
@@ -1032,7 +1063,7 @@ def grid_fill_sprites(self, frame):
for i, button in enumerate(frame.buttons):
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
- def update_alttpr_sprites(self):
+ def update_remote_sprites(self):
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
self.window.destroy()
self.parent.update()
@@ -1045,7 +1076,8 @@ def on_finish(successful, resultmessage):
messagebox.showerror("Sprite Updater", resultmessage)
SpriteSelector(self.parent, self.callback, self.adjuster)
- BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
+ BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites",
+ on_finish, self.repository_url.get())
def browse_for_sprite(self):
sprite = filedialog.askopenfilename(
@@ -1135,12 +1167,13 @@ def deploy_icons(self):
os.makedirs(self.custom_sprite_dir)
@property
- def alttpr_sprite_dir(self):
- return user_path("data", "sprites", "alttpr")
+ def remote_sprite_dir(self):
+ return user_path("data", "sprites", "alttp", "remote")
@property
def custom_sprite_dir(self):
- return user_path("data", "sprites", "custom")
+ return user_path("data", "sprites", "alttp", "custom")
+
def get_image_for_sprite(sprite, gif_only: bool = False):
if not sprite.valid:
diff --git a/MMBN3Client.py b/MMBN3Client.py
index d8ee581bd453..31c6b309b8d7 100644
--- a/MMBN3Client.py
+++ b/MMBN3Client.py
@@ -58,7 +58,7 @@ def _cmd_debug(self):
class MMBN3Context(CommonContext):
command_processor = MMBN3CommandProcessor
game = "MegaMan Battle Network 3"
- items_handling = 0b001 # full local
+ items_handling = 0b101 # full local except starting items
def __init__(self, server_address, password):
super().__init__(server_address, password)
@@ -71,6 +71,7 @@ def __init__(self, server_address, password):
self.auth_name = None
self.slot_data = dict()
self.patching_error = False
+ self.sent_hints = []
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -175,13 +176,16 @@ async def parse_payload(payload: dict, ctx: MMBN3Context, force: bool):
# If trade hinting is enabled, send scout checks
if ctx.slot_data.get("trade_quest_hinting", 0) == 2:
- scouted_locs = [loc.id for loc in scoutable_locations
+ trade_bits = [loc.id for loc in scoutable_locations
if check_location_scouted(loc, payload["locations"])]
- await ctx.send_msgs([{
- "cmd": "LocationScouts",
- "locations": scouted_locs,
- "create_as_hint": 2
- }])
+ scouted_locs = [loc for loc in trade_bits if loc not in ctx.sent_hints]
+ if len(scouted_locs) > 0:
+ ctx.sent_hints.extend(scouted_locs)
+ await ctx.send_msgs([{
+ "cmd": "LocationScouts",
+ "locations": scouted_locs,
+ "create_as_hint": 2
+ }])
def check_location_packet(location, memory):
@@ -282,16 +286,14 @@ async def gba_sync_task(ctx: MMBN3Context):
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.gba_status = CONNECTION_REFUSED_STATUS
+ await asyncio.sleep(1)
continue
async def run_game(romfile):
- options = Utils.get_options().get("mmbn3_options", None)
- if options is None:
- auto_start = True
- else:
- auto_start = options.get("rom_start", True)
- if auto_start:
+ from worlds.mmbn3 import MMBN3World
+ auto_start = MMBN3World.settings.rom_start
+ if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
@@ -366,7 +368,7 @@ async def main():
import colorama
- colorama.init()
+ colorama.just_fix_windows_console()
asyncio.run(main())
colorama.deinit()
diff --git a/Main.py b/Main.py
index fe56dc7d9e09..47a28813fce4 100644
--- a/Main.py
+++ b/Main.py
@@ -1,27 +1,29 @@
import collections
+from collections.abc import Mapping
import concurrent.futures
import logging
import os
-import pickle
import tempfile
import time
+from typing import Any
import zipfile
import zlib
-from typing import Dict, List, Optional, Set, Tuple, Union
import worlds
-from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
-from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
+from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
+from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
+ parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
+from NetUtils import convert_to_base_types
from Options import StartInventoryPool
+from Utils import __version__, output_path, restricted_dumps, version_tuple
from settings import get_settings
-from Utils import __version__, output_path, version_tuple
from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules
__all__ = ["main"]
-def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
+def main(args, seed=None, baked_server_options: dict[str, object] | None = None):
if not baked_server_options:
baked_server_options = get_settings().server_options.as_dict()
assert isinstance(baked_server_options, dict)
@@ -30,377 +32,338 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
output_path.cached_path = args.outputpath
start = time.perf_counter()
- # initialize the world
- world = MultiWorld(args.multi)
+ # initialize the multiworld
+ multiworld = MultiWorld(args.multi)
logger = logging.getLogger()
- 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()
- world.mode = args.mode.copy()
- world.difficulty = args.difficulty.copy()
- world.item_functionality = args.item_functionality.copy()
- world.timer = args.timer.copy()
- world.goal = args.goal.copy()
- world.boss_shuffle = args.shufflebosses.copy()
- world.enemy_health = args.enemy_health.copy()
- 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.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()
- world.green_clock_time = args.green_clock_time.copy()
- world.dungeon_counters = args.dungeon_counters.copy()
- world.triforce_pieces_available = args.triforce_pieces_available.copy()
- world.triforce_pieces_required = args.triforce_pieces_required.copy()
- world.shop_shuffle = args.shop_shuffle.copy()
- world.shuffle_prizes = args.shuffle_prizes.copy()
- world.sprite_pool = args.sprite_pool.copy()
- world.dark_room_logic = args.dark_room_logic.copy()
- world.plando_items = args.plando_items.copy()
- world.plando_texts = args.plando_texts.copy()
- world.plando_connections = args.plando_connections.copy()
- world.required_medallions = args.required_medallions.copy()
- world.game = args.game.copy()
- world.player_name = args.name.copy()
- world.sprite = args.sprite.copy()
- world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
-
- world.set_options(args)
- world.set_item_links()
- world.state = CollectionState(world)
- logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
+ multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
+ multiworld.plando_options = args.plando
+ multiworld.game = args.game.copy()
+ multiworld.player_name = args.name.copy()
+ multiworld.sprite = args.sprite.copy()
+ multiworld.sprite_pool = args.sprite_pool.copy()
+
+ multiworld.set_options(args)
+ if args.csv_output:
+ from Options import dump_player_options
+ dump_player_options(multiworld)
+ multiworld.set_item_links()
+ multiworld.state = CollectionState(multiworld)
+ logger.info('Archipelago Version %s - Seed: %s\n', __version__, multiworld.seed)
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
- max_location = 0
- for cls in AutoWorld.AutoWorldRegister.world_types.values():
- if cls.item_id_to_name:
- max_item = max(max_item, max(cls.item_id_to_name))
- max_location = max(max_location, max(cls.location_id_to_name))
+ world_classes = AutoWorld.AutoWorldRegister.world_types.values()
- item_digits = len(str(max_item))
- location_digits = len(str(max_location))
- item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
- location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
- del max_item, max_location
+ version_count = max(len(cls.world_version.as_simple_string()) for cls in world_classes)
+ item_count = len(str(max(len(cls.item_names) for cls in world_classes)))
+ location_count = len(str(max(len(cls.location_names) for cls in world_classes)))
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0:
- logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
- f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
- f"{max(cls.item_id_to_name):{item_digits}}) | "
- f"{len(cls.location_names):{location_count}} "
- f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
- f"{max(cls.location_id_to_name):{location_digits}})")
+ logger.info(f" {name:{longest_name}}: "
+ f"v{cls.world_version.as_simple_string():{version_count}} | "
+ f"Items: {len(cls.item_names):{item_count}} | "
+ f"Locations: {len(cls.location_names):{location_count}}")
- del item_digits, location_digits, item_count, location_count
+ del item_count, location_count
- AutoWorld.call_stage(world, "assert_generate")
+ # This assertion method should not be necessary to run if we are not outputting any multidata.
+ if not args.skip_output and not args.spoiler_only:
+ AutoWorld.call_stage(multiworld, "assert_generate")
- AutoWorld.call_all(world, "generate_early")
+ AutoWorld.call_all(multiworld, "generate_early")
logger.info('')
- for player in world.player_ids:
- for item_name, count in world.start_inventory[player].value.items():
+ for player in multiworld.player_ids:
+ for item_name, count in multiworld.worlds[player].options.start_inventory.value.items():
for _ in range(count):
- world.push_precollected(world.create_item(item_name, player))
+ multiworld.push_precollected(multiworld.create_item(item_name, player))
- for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
+ for item_name, count in getattr(multiworld.worlds[player].options,
+ "start_inventory_from_pool",
+ StartInventoryPool({})).value.items():
for _ in range(count):
- world.push_precollected(world.create_item(item_name, player))
+ multiworld.push_precollected(multiworld.create_item(item_name, player))
+ # remove from_pool items also from early items handling, as starting is plenty early.
+ early = multiworld.early_items[player].get(item_name, 0)
+ if early:
+ multiworld.early_items[player][item_name] = max(0, early-count)
+ remaining_count = count-early
+ if remaining_count > 0:
+ local_early = multiworld.local_early_items[player].get(item_name, 0)
+ if local_early:
+ multiworld.early_items[player][item_name] = max(0, local_early - remaining_count)
+ del local_early
+ del early
- logger.info('Creating World.')
- AutoWorld.call_all(world, "create_regions")
+ # items can't be both local and non-local, prefer local
+ multiworld.worlds[player].options.non_local_items.value -= multiworld.worlds[player].options.local_items.value
+ multiworld.worlds[player].options.non_local_items.value -= set(multiworld.local_early_items[player])
- logger.info('Creating Items.')
- AutoWorld.call_all(world, "create_items")
+ # Clear non-applicable local and non-local items.
+ if multiworld.players == 1:
+ multiworld.worlds[1].options.non_local_items.value = set()
+ multiworld.worlds[1].options.local_items.value = set()
- # 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('Creating MultiWorld.')
+ AutoWorld.call_all(multiworld, "create_regions")
- logger.info('Calculating Access Rules.')
+ logger.info('Creating Items.')
+ AutoWorld.call_all(multiworld, "create_items")
- 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])
+ logger.info('Calculating Access Rules.')
+ AutoWorld.call_all(multiworld, "set_rules")
+
+ for player in multiworld.player_ids:
+ exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
+ multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
+ world_excluded_locations = set()
+ for location_name in multiworld.worlds[player].options.priority_locations.value:
+ try:
+ location = multiworld.get_location(location_name, player)
+ except KeyError:
+ continue
+
+ if location.progress_type != LocationProgressType.EXCLUDED:
+ location.progress_type = LocationProgressType.PRIORITY
+ else:
+ logger.warning(f"Unable to prioritize location \"{location_name}\" in player {player}'s world because the world excluded it.")
+ world_excluded_locations.add(location_name)
+ multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
- AutoWorld.call_all(world, "set_rules")
+ # Set local and non-local item rules.
+ # This function is called so late because worlds might otherwise overwrite item_rules which are how locality works
+ if multiworld.players > 1:
+ locality_rules(multiworld)
- for player in world.player_ids:
- exclusion_rules(world, player, world.exclude_locations[player].value)
- world.priority_locations[player].value -= world.exclude_locations[player].value
- for location_name in world.priority_locations[player].value:
- world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY
+ multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
- # Set local and non-local item rules.
- if world.players > 1:
- locality_rules(world)
- else:
- world.non_local_items[1].value = set()
- world.local_items[1].value = set()
-
- AutoWorld.call_all(world, "generate_basic")
+ AutoWorld.call_all(multiworld, "connect_entrances")
+ AutoWorld.call_all(multiworld, "generate_basic")
# remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible.
- if any(world.start_inventory_from_pool[player].value for player in world.player_ids):
- new_items: List[Item] = []
- depletion_pool: Dict[int, Dict[str, int]] = {
- player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids}
- for player, items in depletion_pool.items():
- player_world: AutoWorld.World = world.worlds[player]
- for count in items.values():
- new_items.append(player_world.create_filler())
- target: int = sum(sum(items.values()) for items in depletion_pool.values())
- for i, item in enumerate(world.itempool):
+ fallback_inventory = StartInventoryPool({})
+ depletion_pool: dict[int, dict[str, int]] = {
+ player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
+ for player in multiworld.player_ids
+ }
+ target_per_player = {
+ player: sum(target_items.values()) for player, target_items in depletion_pool.items() if target_items
+ }
+
+ if target_per_player:
+ new_itempool: list[Item] = []
+
+ # Make new itempool with start_inventory_from_pool items removed
+ for item in multiworld.itempool:
if depletion_pool[item.player].get(item.name, 0):
- target -= 1
depletion_pool[item.player][item.name] -= 1
- # quick abort if we have found all items
- if not target:
- new_items.extend(world.itempool[i+1:])
- break
- else:
- new_items.append(item)
-
- # leftovers?
- if target:
- for player, remaining_items in depletion_pool.items():
- remaining_items = {name: count for name, count in remaining_items.items() if count}
- if remaining_items:
- raise Exception(f"{world.get_player_name(player)}"
- f" is trying to remove items from their pool that don't exist: {remaining_items}")
- world.itempool[:] = new_items
-
- # temporary home for item links, should be moved out of Main
- for group_id, group in world.groups.items():
- def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
- Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
- ]:
- classifications: Dict[str, int] = collections.defaultdict(int)
- counters = {player: {name: 0 for name in shared_pool} for player in players}
- for item in world.itempool:
- if item.player in counters and item.name in shared_pool:
- counters[item.player][item.name] += 1
- classifications[item.name] |= item.classification
-
- for player in players.copy():
- if all([counters[player][item] == 0 for item in shared_pool]):
- players.remove(player)
- del (counters[player])
-
- if not players:
- return None, None
-
- for item in shared_pool:
- count = min(counters[player][item] for player in players)
- if count:
- for player in players:
- counters[player][item] = count
- else:
- for player in players:
- del (counters[player][item])
- return counters, classifications
-
- common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
- if not common_item_count:
- continue
-
- new_itempool: List[Item] = []
- for item_name, item_count in next(iter(common_item_count.values())).items():
- for _ in range(item_count):
- new_item = group["world"].create_item(item_name)
- # mangle together all original classification bits
- new_item.classification |= classifications[item_name]
- new_itempool.append(new_item)
-
- region = Region("Menu", group_id, world, "ItemLink")
- world.regions.append(region)
- locations = region.locations = []
- for item in world.itempool:
- count = common_item_count.get(item.player, {}).get(item.name, 0)
- if count:
- loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}",
- None, region)
- loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
- state.has(item_name, group_id_, count_)
-
- locations.append(loc)
- loc.place_locked_item(item)
- common_item_count[item.player][item.name] -= 1
else:
new_itempool.append(item)
- itemcount = len(world.itempool)
- world.itempool = new_itempool
+ # Create filler in place of the removed items, warn if any items couldn't be found in the multiworld itempool
+ for player, target in target_per_player.items():
+ unfound_items = {item: count for item, count in depletion_pool[player].items() if count}
- 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", item_player,
- group["replacement_items"][player]))
- else:
- 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)])
+ if unfound_items:
+ player_name = multiworld.get_player_name(player)
+ logger.warning(f"{player_name} tried to remove items from their pool that don't exist: {unfound_items}")
- if any(world.item_links.values()):
- world._recache()
- world._all_state = None
+ needed_items = target_per_player[player] - sum(unfound_items.values())
+ new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)]
- logger.info("Running Item Plando")
+ assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change."
+ multiworld.itempool[:] = new_itempool
- distribute_planned(world)
+ multiworld.link_items()
+
+ if any(world.options.item_links for world in multiworld.worlds.values()):
+ multiworld._all_state = None
+
+ logger.info("Running Item Plando.")
+ resolve_early_locations_for_planned(multiworld)
+ distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks
+ for x in multiworld.plando_item_blocks[player]])
logger.info('Running Pre Main Fill.')
- AutoWorld.call_all(world, "pre_fill")
+ AutoWorld.call_all(multiworld, "pre_fill")
- logger.info(f'Filling the world with {len(world.itempool)} items.')
+ logger.info(f'Filling the multiworld with {len(multiworld.itempool)} items.')
- if world.algorithm == 'flood':
- flood_items(world) # different algo, biased towards early game progress items
- elif world.algorithm == 'balanced':
- distribute_items_restrictive(world)
+ if multiworld.algorithm == 'flood':
+ flood_items(multiworld) # different algo, biased towards early game progress items
+ elif multiworld.algorithm == 'balanced':
+ distribute_items_restrictive(multiworld, get_settings().generator.panic_method)
- AutoWorld.call_all(world, 'post_fill')
+ AutoWorld.call_all(multiworld, 'post_fill')
- if world.players > 1 and not args.skip_prog_balancing:
- balance_multiworld_progression(world)
+ if multiworld.players > 1 and not args.skip_prog_balancing:
+ balance_multiworld_progression(multiworld)
else:
logger.info("Progression balancing skipped.")
+ # we're about to output using multithreading, so we're removing the global random state to prevent accidental use
+ multiworld.random.passthrough = False
+
+ if args.skip_output:
+ logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start)
+ return multiworld
+
logger.info(f'Beginning output...')
+ outfilebase = 'AP_' + multiworld.seed_name
- # we're about to output using multithreading, so we're removing the global random state to prevent accidental use
- world.random.passthrough = False
+ if args.spoiler_only:
+ if args.spoiler > 1:
+ logger.info('Calculating playthrough.')
+ multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
- outfilebase = 'AP_' + world.seed_name
+ multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
+ logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start)
+ return multiworld
output = tempfile.TemporaryDirectory()
with output as temp_dir:
- with concurrent.futures.ThreadPoolExecutor(world.players + 2) as pool:
- check_accessibility_task = pool.submit(world.fulfills_accessibility)
+ output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
+ is not multiworld.worlds[player].generate_output.__code__]
+ with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool:
+ check_accessibility_task = pool.submit(multiworld.fulfills_accessibility)
- output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
- for player in world.player_ids:
+ output_file_futures = [pool.submit(AutoWorld.call_stage, multiworld, "generate_output", temp_dir)]
+ for player in output_players:
# skip starting a thread for methods that say "pass".
- if AutoWorld.World.generate_output.__code__ is not world.worlds[player].generate_output.__code__:
- output_file_futures.append(
- pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
+ output_file_futures.append(
+ pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
# collect ER hint info
- er_hint_data: Dict[int, Dict[int, str]] = {}
- AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
+ er_hint_data: dict[int, dict[int, str]] = {}
+ AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
def write_multidata():
import NetUtils
- slot_data = {}
- client_versions = {}
- games = {}
- minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
- slot_info = {}
- names = [[name for player, name in sorted(world.player_name.items())]]
- for slot in world.player_ids:
- player_world: AutoWorld.World = world.worlds[slot]
+ from NetUtils import HintStatus
+ slot_data: dict[int, Mapping[str, Any]] = {}
+ client_versions: dict[int, tuple[int, int, int]] = {}
+ games: dict[int, str] = {}
+ minimum_versions: NetUtils.MinimumVersions = {
+ "server": AutoWorld.World.required_server_version, "clients": client_versions
+ }
+ slot_info: dict[int, NetUtils.NetworkSlot] = {}
+ names = [[name for player, name in sorted(multiworld.player_name.items())]]
+ for slot in multiworld.player_ids:
+ player_world: AutoWorld.World = multiworld.worlds[slot]
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
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])
- 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],
+ games[slot] = multiworld.game[slot]
+ slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], multiworld.game[slot],
+ multiworld.player_types[slot])
+ for slot, group in multiworld.groups.items():
+ games[slot] = multiworld.game[slot]
+ slot_info[slot] = NetUtils.NetworkSlot(group["name"], multiworld.game[slot], multiworld.player_types[slot],
group_members=sorted(group["players"]))
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
- for player, world_precollected in world.precollected_items.items()}
- precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
+ for player, world_precollected in multiworld.precollected_items.items()}
+ precollected_hints: dict[int, set[NetUtils.Hint]] = {
+ player: set() for player in range(1, multiworld.players + 1 + len(multiworld.groups))
+ }
- for slot in world.player_ids:
- slot_data[slot] = world.worlds[slot].fill_slot_data()
+ for slot in multiworld.player_ids:
+ slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
- def precollect_hint(location):
+ def precollect_hint(location: Location, auto_status: HintStatus):
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
hint = NetUtils.Hint(location.item.player, location.player, location.address,
- location.item.code, False, entrance, location.item.flags)
+ location.item.code, False, entrance, location.item.flags, auto_status)
precollected_hints[location.player].add(hint)
- if location.item.player not in world.groups:
+ if location.item.player not in multiworld.groups:
precollected_hints[location.item.player].add(hint)
else:
- for player in world.groups[location.item.player]["players"]:
+ for player in multiworld.groups[location.item.player]["players"]:
precollected_hints[player].add(hint)
- locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids}
- for location in world.get_filled_locations():
+ locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
+ for location in multiworld.get_filled_locations():
if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None. Location: " \
- f" {location}"
+ f" {location}, Item: {location.item}"
+ assert location.address not in locations_data[location.player], (
+ f"Locations with duplicate address. {location} and "
+ f"{locations_data[location.player][location.address]}")
locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags
- if location.name in world.start_location_hints[location.player]:
- precollect_hint(location)
- elif location.item.name in world.start_hints[location.item.player]:
- precollect_hint(location)
- elif any([location.item.name in world.start_hints[player]
- for player in world.groups.get(location.item.player, {}).get("players", [])]):
- precollect_hint(location)
+ auto_status = HintStatus.HINT_AVOID if location.item.trap else HintStatus.HINT_PRIORITY
+ if location.name in multiworld.worlds[location.player].options.start_location_hints:
+ if not location.item.trap: # Unspecified status for location hints, except traps
+ auto_status = HintStatus.HINT_UNSPECIFIED
+ precollect_hint(location, auto_status)
+ elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
+ precollect_hint(location, auto_status)
+ elif any([location.item.name in multiworld.worlds[player].options.start_hints
+ for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
+ precollect_hint(location, auto_status)
# embedded data package
data_package = {
game_world.game: worlds.network_data_package["games"][game_world.game]
- for game_world in world.worlds.values()
+ for game_world in multiworld.worlds.values()
}
+ data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
- checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
+ checks_in_area: dict[int, dict[str, int | list[int]]] = {}
- multidata = {
+ # get spheres -> filter address==None -> skip empty
+ spheres: list[dict[int, set[int]]] = []
+ for sphere in multiworld.get_sendable_spheres():
+ current_sphere: dict[int, set[int]] = collections.defaultdict(set)
+ for sphere_location in sphere:
+ current_sphere[sphere_location.player].add(sphere_location.address)
+
+ if current_sphere:
+ spheres.append(dict(current_sphere))
+
+ multidata: NetUtils.MultiData = {
"slot_data": slot_data,
"slot_info": slot_info,
- "connect_names": {name: (0, player) for player, name in world.player_name.items()},
+ "connect_names": {name: (0, player) for player, name in multiworld.player_name.items()},
"locations": locations_data,
"checks_in_area": checks_in_area,
"server_options": baked_server_options,
"er_hint_data": er_hint_data,
"precollected_items": precollected_items,
"precollected_hints": precollected_hints,
- "version": tuple(version_tuple),
+ "version": (version_tuple.major, version_tuple.minor, version_tuple.build),
"tags": ["AP"],
"minimum_versions": minimum_versions,
- "seed_name": world.seed_name,
+ "seed_name": multiworld.seed_name,
+ "spheres": spheres,
"datapackage": data_package,
+ "race_mode": int(multiworld.is_race),
}
- AutoWorld.call_all(world, "modify_multidata", multidata)
+ # TODO: change to `"version": version_tuple` after getting better serialization
+ AutoWorld.call_all(multiworld, "modify_multidata", multidata)
+
+ for key in ("slot_data", "er_hint_data"):
+ multidata[key] = convert_to_base_types(multidata[key])
- multidata = zlib.compress(pickle.dumps(multidata), 9)
+ serialized_multidata = zlib.compress(restricted_dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([3])) # version of format
- f.write(multidata)
+ f.write(serialized_multidata)
- multidata_task = pool.submit(write_multidata)
+ output_file_futures.append(pool.submit(write_multidata))
if not check_accessibility_task.result():
- if not world.can_beat_game():
- raise Exception("Game appears as unbeatable. Aborting.")
+ if not multiworld.can_beat_game():
+ raise FillError("Game appears as unbeatable. Aborting.", multiworld=multiworld)
else:
logger.warning("Location Accessibility requirements not fulfilled.")
# retrieve exceptions via .result() if they occurred.
- multidata_task.result()
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
if i % 10 == 0 or i == len(output_file_futures):
logger.info(f'Generating output files ({i}/{len(output_file_futures)}).')
@@ -408,12 +371,12 @@ def precollect_hint(location):
if args.spoiler > 1:
logger.info('Calculating playthrough.')
- world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
+ multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
if args.spoiler:
- world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
+ multiworld.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
- zipfilename = output_path(f"AP_{world.seed_name}.zip")
+ zipfilename = output_path(f"AP_{multiworld.seed_name}.zip")
logger.info(f"Creating final archive at {zipfilename}")
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
@@ -421,4 +384,4 @@ def precollect_hint(location):
zf.write(file.path, arcname=file.name)
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
- return world
+ return multiworld
diff --git a/MinecraftClient.py b/MinecraftClient.py
deleted file mode 100644
index 93385ec5385e..000000000000
--- a/MinecraftClient.py
+++ /dev/null
@@ -1,344 +0,0 @@
-import argparse
-import json
-import os
-import sys
-import re
-import atexit
-import shutil
-from subprocess import Popen
-from shutil import copyfile
-from time import strftime
-import logging
-
-import requests
-
-import Utils
-from Utils import is_windows
-
-atexit.register(input, "Press enter to exit.")
-
-# 1 or more digits followed by m or g, then optional b
-max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
-
-
-def prompt_yes_no(prompt):
- yes_inputs = {'yes', 'ye', 'y'}
- no_inputs = {'no', 'n'}
- while True:
- choice = input(prompt + " [y/n] ").lower()
- if choice in yes_inputs:
- return True
- elif choice in no_inputs:
- return False
- else:
- print('Please respond with "y" or "n".')
-
-
-def find_ap_randomizer_jar(forge_dir):
- """Create mods folder if needed; find AP randomizer jar; return None if not found."""
- mods_dir = os.path.join(forge_dir, 'mods')
- if os.path.isdir(mods_dir):
- for entry in os.scandir(mods_dir):
- if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
- logging.info(f"Found AP randomizer mod: {entry.name}")
- return entry.name
- return None
- else:
- os.mkdir(mods_dir)
- logging.info(f"Created mods folder in {forge_dir}")
- return None
-
-
-def replace_apmc_files(forge_dir, apmc_file):
- """Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
- if apmc_file is None:
- return
- apdata_dir = os.path.join(forge_dir, 'APData')
- copy_apmc = True
- if not os.path.isdir(apdata_dir):
- os.mkdir(apdata_dir)
- logging.info(f"Created APData folder in {forge_dir}")
- for entry in os.scandir(apdata_dir):
- if entry.name.endswith(".apmc") and entry.is_file():
- if not os.path.samefile(apmc_file, entry.path):
- os.remove(entry.path)
- logging.info(f"Removed {entry.name} in {apdata_dir}")
- else: # apmc already in apdata
- copy_apmc = False
- if copy_apmc:
- copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
- logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
-
-
-def read_apmc_file(apmc_file):
- from base64 import b64decode
-
- with open(apmc_file, 'r') as f:
- return json.loads(b64decode(f.read()))
-
-
-def update_mod(forge_dir, url: str):
- """Check mod version, download new mod from GitHub releases page if needed. """
- ap_randomizer = find_ap_randomizer_jar(forge_dir)
- os.path.basename(url)
- if ap_randomizer is not None:
- logging.info(f"Your current mod is {ap_randomizer}.")
- else:
- logging.info(f"You do not have the AP randomizer mod installed.")
-
- if ap_randomizer != os.path.basename(url):
- logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
- f"{os.path.basename(url)}")
- if prompt_yes_no("Would you like to update?"):
- old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
- new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
- logging.info("Downloading AP randomizer mod. This may take a moment...")
- apmod_resp = requests.get(url)
- if apmod_resp.status_code == 200:
- with open(new_ap_mod, 'wb') as f:
- f.write(apmod_resp.content)
- logging.info(f"Wrote new mod file to {new_ap_mod}")
- if old_ap_mod is not None:
- os.remove(old_ap_mod)
- logging.info(f"Removed old mod file from {old_ap_mod}")
- else:
- logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
- logging.error(f"Please report this issue on the Archipelago Discord server.")
- sys.exit(1)
-
-
-def check_eula(forge_dir):
- """Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
- eula_path = os.path.join(forge_dir, "eula.txt")
- if not os.path.isfile(eula_path):
- # Create eula.txt
- with open(eula_path, 'w') as f:
- f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
- f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
- f.write("eula=false\n")
- with open(eula_path, 'r+') as f:
- text = f.read()
- if 'false' in text:
- # Prompt user to agree to the EULA
- logging.info("You need to agree to the Minecraft EULA in order to run the server.")
- logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
- if prompt_yes_no("Do you agree to the EULA?"):
- f.seek(0)
- f.write(text.replace('false', 'true'))
- f.truncate()
- logging.info(f"Set {eula_path} to true")
- else:
- sys.exit(0)
-
-
-def find_jdk_dir(version: str) -> str:
- """get the specified versions jdk directory"""
- for entry in os.listdir():
- if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
- return os.path.abspath(entry)
-
-
-def find_jdk(version: str) -> str:
- """get the java exe location"""
-
- if is_windows:
- jdk = find_jdk_dir(version)
- jdk_exe = os.path.join(jdk, "bin", "java.exe")
- if os.path.isfile(jdk_exe):
- return jdk_exe
- else:
- jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
- if not jdk_exe:
- raise Exception("Could not find Java. Is Java installed on the system?")
- return jdk_exe
-
-
-def download_java(java: str):
- """Download Corretto (Amazon JDK)"""
-
- jdk = find_jdk_dir(java)
- if jdk is not None:
- print(f"Removing old JDK...")
- from shutil import rmtree
- rmtree(jdk)
-
- print(f"Downloading Java...")
- jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
- resp = requests.get(jdk_url)
- if resp.status_code == 200: # OK
- print(f"Extracting...")
- import zipfile
- from io import BytesIO
- with zipfile.ZipFile(BytesIO(resp.content)) as zf:
- zf.extractall()
- else:
- print(f"Error downloading Java (status code {resp.status_code}).")
- print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
- if not prompt_yes_no("Continue anyways?"):
- sys.exit(0)
-
-
-def install_forge(directory: str, forge_version: str, java_version: str):
- """download and install forge"""
-
- java_exe = find_jdk(java_version)
- if java_exe is not None:
- print(f"Downloading Forge {forge_version}...")
- forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
- resp = requests.get(forge_url)
- if resp.status_code == 200: # OK
- forge_install_jar = os.path.join(directory, "forge_install.jar")
- if not os.path.exists(directory):
- os.mkdir(directory)
- with open(forge_install_jar, 'wb') as f:
- f.write(resp.content)
- print(f"Installing Forge...")
- install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
- install_process.wait()
- os.remove(forge_install_jar)
-
-
-def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
- """Run the Forge server."""
-
- java_exe = find_jdk(java_version)
- if not os.path.isfile(java_exe):
- java_exe = "java" # try to fall back on java in the PATH
-
- heap_arg = max_heap_re.match(heap_arg).group()
- if heap_arg[-1] in ['b', 'B']:
- heap_arg = heap_arg[:-1]
- heap_arg = "-Xmx" + heap_arg
-
- os_args = "win_args.txt" if is_windows else "unix_args.txt"
- args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
- forge_args = []
- with open(args_file) as argfile:
- for line in argfile:
- forge_args.extend(line.strip().split(" "))
-
- args = [java_exe, heap_arg, *forge_args, "-nogui"]
- logging.info(f"Running Forge server: {args}")
- os.chdir(forge_dir)
- return Popen(args)
-
-
-def get_minecraft_versions(version, release_channel="release"):
- version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
- resp = requests.get(version_file_endpoint)
- local = False
- if resp.status_code == 200: # OK
- try:
- data = resp.json()
- except requests.exceptions.JSONDecodeError:
- logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
- local = True
- else:
- logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
- local = True
-
- if local:
- with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
- data = json.load(f)
- else:
- with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
- json.dump(data, f)
-
- try:
- if version:
- return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
- else:
- return resp.json()[release_channel][0]
- except (StopIteration, KeyError):
- logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
- if release_channel != "release":
- logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
- else:
- logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
- sys.exit(0)
-
-
-def is_correct_forge(forge_dir) -> bool:
- if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
- return True
- return False
-
-
-if __name__ == '__main__':
- Utils.init_logging("MinecraftClient")
- parser = argparse.ArgumentParser()
- parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
- parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
- help="Download and install Java and the Forge server. Does not launch the client afterwards.")
- parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
- help="Specify release channel to use.")
- parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
- help="specify java version.")
- parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
- help="specify forge version. (Minecraft Version-Forge Version)")
- parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
- help="specify Mod data version to download.")
-
- args = parser.parse_args()
- apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
-
- # Change to executable's working directory
- os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
-
- options = Utils.get_options()
- channel = args.channel or options["minecraft_options"]["release_channel"]
- apmc_data = None
- data_version = args.data_version or None
-
- if apmc_file is None and not args.install:
- apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
-
- if apmc_file is not None and data_version is None:
- apmc_data = read_apmc_file(apmc_file)
- data_version = apmc_data.get('client_version', '')
-
- versions = get_minecraft_versions(data_version, channel)
-
- forge_dir = options["minecraft_options"]["forge_directory"]
- max_heap = options["minecraft_options"]["max_heap_size"]
- forge_version = args.forge or versions["forge"]
- java_version = args.java or versions["java"]
- mod_url = versions["url"]
- java_dir = find_jdk_dir(java_version)
-
- if args.install:
- if is_windows:
- print("Installing Java")
- download_java(java_version)
- if not is_correct_forge(forge_dir):
- print("Installing Minecraft Forge")
- install_forge(forge_dir, forge_version, java_version)
- else:
- print("Correct Forge version already found, skipping install.")
- sys.exit(0)
-
- if apmc_data is None:
- raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
-
- if is_windows:
- if java_dir is None or not os.path.isdir(java_dir):
- if prompt_yes_no("Did not find java directory. Download and install java now?"):
- download_java(java_version)
- java_dir = find_jdk_dir(java_version)
- if java_dir is None or not os.path.isdir(java_dir):
- raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
-
- if not is_correct_forge(forge_dir):
- if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
- install_forge(forge_dir, forge_version, java_version)
- if not os.path.isdir(forge_dir):
- raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
-
- if not max_heap_re.match(max_heap):
- raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
-
- update_mod(forge_dir, mod_url)
- replace_apmc_files(forge_dir, apmc_file)
- check_eula(forge_dir)
- server_process = run_forge_server(forge_dir, java_version, max_heap)
- server_process.wait()
diff --git a/ModuleUpdate.py b/ModuleUpdate.py
index 209f2da67253..db42f8e5abcb 100644
--- a/ModuleUpdate.py
+++ b/ModuleUpdate.py
@@ -4,14 +4,40 @@
import multiprocessing
import warnings
-local_dir = os.path.dirname(__file__)
-requirements_files = {os.path.join(local_dir, 'requirements.txt')}
-if sys.version_info < (3, 8, 6):
- raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
+if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9):
+ # Official micro version updates. This should match the number in docs/running from source.md.
+ raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.11.9+ is supported.")
+elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 13):
+ # There are known security issues, but no easy way to install fixed versions on Windows for testing.
+ warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
+elif sys.version_info < (3, 11, 0):
+ # Other platforms may get security backports instead of micro updates, so the number is unreliable.
+ raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0+ is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
-update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process()
+_skip_update = bool(
+ getattr(sys, "frozen", False) or
+ multiprocessing.parent_process() or
+ os.environ.get("SKIP_REQUIREMENTS_UPDATE", "").lower() in ("1", "true", "yes")
+)
+update_ran = _skip_update
+
+
+class RequirementsSet(set):
+ def add(self, e):
+ global update_ran
+ update_ran &= _skip_update
+ super().add(e)
+
+ def update(self, *s):
+ global update_ran
+ update_ran &= _skip_update
+ super().update(*s)
+
+
+local_dir = os.path.dirname(__file__)
+requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),))
if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")):
@@ -48,33 +74,42 @@ def update_command():
def install_pkg_resources(yes=False):
try:
import pkg_resources # noqa: F401
- except ImportError:
+ except (AttributeError, ImportError):
check_pip()
if not yes:
confirm("pkg_resources not found, press enter to install it")
- subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
+ subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"])
-def update(yes=False, force=False):
+def update(yes: bool = False, force: bool = False) -> None:
global update_ran
if not update_ran:
update_ran = True
+ install_pkg_resources(yes=yes)
+ import pkg_resources
+
if force:
update_command()
return
- install_pkg_resources(yes=yes)
- import pkg_resources
-
+ prev = "" # if a line ends in \ we store here and merge later
for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
if not os.path.exists(path):
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 not line or line.lstrip(" \t")[0] == "#":
+ if not prev:
+ continue # ignore comments
+ line = ""
+ elif line.rstrip("\r\n").endswith("\\"):
+ prev = prev + line.rstrip("\r\n")[:-1] + " " # continue on next line
+ continue
+ line = prev + line
+ line = line.split("--hash=")[0] # remove hashes from requirement for version checking
+ prev = ""
if line.startswith(("https://", "git+https://")):
# extract name and version for url
rest = line.split('/')[-1]
diff --git a/MultiServer.py b/MultiServer.py
index 8be8d641324a..5e370d17178a 100644
--- a/MultiServer.py
+++ b/MultiServer.py
@@ -2,17 +2,20 @@
import argparse
import asyncio
-import copy
import collections
+import contextlib
+import copy
import datetime
import functools
import hashlib
import inspect
import itertools
import logging
+import math
import operator
import pickle
import random
+import shlex
import threading
import time
import typing
@@ -25,9 +28,11 @@
if typing.TYPE_CHECKING:
import ssl
+ from NetUtils import ServerConnection
-import websockets
import colorama
+import websockets
+from websockets.extensions.permessage_deflate import PerMessageDeflate, ServerPerMessageDeflateFactory
try:
# ponyorm is a requirement for webhost, not default server, so may not be importable
from pony.orm.dbapiprovider import OperationalError
@@ -36,12 +41,23 @@
import NetUtils
import Utils
-from Utils import version_tuple, restricted_loads, Version, async_start
+from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
- SlotType, LocationStore
+ SlotType, LocationStore, MultiData, Hint, HintStatus
+from BaseClasses import ItemClassification
+
+
+min_client_version = Version(0, 5, 0)
+colorama.just_fix_windows_console()
-min_client_version = Version(0, 1, 6)
-colorama.init()
+no_version = Version(0, 0, 0)
+assert isinstance(no_version, tuple) # assert immutable
+
+server_per_message_deflate_factory = ServerPerMessageDeflateFactory(
+ server_max_window_bits=11,
+ client_max_window_bits=11,
+ compress_settings={"memLevel": 4},
+)
def remove_from_list(container, value):
@@ -60,31 +76,54 @@ def pop_from_container(container, value):
return container
-def update_dict(dictionary, entries):
- dictionary.update(entries)
- return dictionary
+def update_container_unique(container, entries):
+ if isinstance(container, list):
+ existing_container_as_set = set(container)
+ container.extend([entry for entry in entries if entry not in existing_container_as_set])
+ else:
+ container.update(entries)
+ return container
+
+
+def queue_gc():
+ import gc
+ from threading import Thread
+
+ gc_thread: typing.Optional[Thread] = getattr(queue_gc, "_thread", None)
+ def async_collect():
+ time.sleep(2)
+ setattr(queue_gc, "_thread", None)
+ gc.collect()
+ if not gc_thread:
+ gc_thread = Thread(target=async_collect)
+ setattr(queue_gc, "_thread", gc_thread)
+ gc_thread.start()
# functions callable on storable data on the server by clients
modify_functions = {
+ # generic:
+ "replace": lambda old, new: new,
+ "default": lambda old, new: old,
+ # numeric:
"add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append)
"mul": operator.mul,
+ "pow": operator.pow,
"mod": operator.mod,
+ "floor": lambda value, _: math.floor(value),
+ "ceil": lambda value, _: math.ceil(value),
"max": max,
"min": min,
- "replace": lambda old, new: new,
- "default": lambda old, new: old,
- "pow": operator.pow,
# bitwise:
"xor": operator.xor,
"or": operator.or_,
"and": operator.and_,
"left_shift": operator.lshift,
"right_shift": operator.rshift,
- # lists/dicts
+ # lists/dicts:
"remove": remove_from_list,
"pop": pop_from_container,
- "update": update_dict,
+ "update": update_container_unique,
}
@@ -95,15 +134,40 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
class Client(Endpoint):
- version = Version(0, 0, 0)
- tags: typing.List[str] = []
+ __slots__ = (
+ "__weakref__",
+ "version",
+ "auth",
+ "team",
+ "slot",
+ "send_index",
+ "tags",
+ "messageprocessor",
+ "ctx",
+ "remote_items",
+ "remote_start_inventory",
+ "no_items",
+ "no_locations",
+ "no_text",
+ )
+
+ version: Version
+ auth: bool
+ team: int | None
+ slot: int | None
+ send_index: int
+ tags: list[str]
+ messageprocessor: ClientMessageProcessor
+ ctx: weakref.ref[Context]
remote_items: bool
remote_start_inventory: bool
no_items: bool
no_locations: bool
+ no_text: bool
- def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
+ def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
super().__init__(socket)
+ self.version = no_version
self.auth = False
self.team = None
self.slot = None
@@ -111,6 +175,11 @@ def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
self.tags = []
self.messageprocessor = client_message_processor(ctx, self)
self.ctx = weakref.ref(ctx)
+ self.remote_items = False
+ self.remote_start_inventory = False
+ self.no_items = False
+ self.no_locations = False
+ self.no_text = False
@property
def items_handling(self):
@@ -148,10 +217,12 @@ class Context:
"release_mode": str,
"remaining_mode": str,
"collect_mode": str,
+ "countdown_mode": str,
"item_cheat": bool,
"compatibility": int}
# team -> slot id -> list of clients authenticated to slot.
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
+ endpoints: list[Client]
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
hints_used: typing.Dict[typing.Tuple[int, int], int]
@@ -163,18 +234,22 @@ class Context:
slot_info: typing.Dict[int, NetworkSlot]
generator_version = Version(0, 0, 0)
checksums: typing.Dict[str, str]
- item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
+ item_names: typing.Dict[str, typing.Dict[int, str]]
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
- location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
+ location_names: typing.Dict[str, typing.Dict[int, str]]
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
- non_hintable_names: typing.Dict[str, typing.Set[str]]
+ non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
+ spheres: typing.List[typing.Dict[int, typing.Set[int]]]
+ """ each sphere is { player: { location_id, ... } } """
+ logger: logging.Logger
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
- remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
- log_network: bool = False):
+ countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
+ compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
+ self.logger = logger
super(Context, self).__init__()
self.slot_info = {}
self.log_network = log_network
@@ -202,10 +277,11 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo
self.hint_cost = hint_cost
self.location_check_points = location_check_points
self.hints_used = collections.defaultdict(int)
- self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
+ self.hints: typing.Dict[team_slot, typing.Set[Hint]] = collections.defaultdict(set)
self.release_mode: str = release_mode
self.remaining_mode: str = remaining_mode
self.collect_mode: str = collect_mode
+ self.countdown_mode: str = countdown_mode
self.item_cheat = item_cheat
self.exit_event = asyncio.Event()
self.client_activity_timers: typing.Dict[
@@ -219,7 +295,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo
self.embedded_blacklist = {"host", "port"}
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
self.auto_save_interval = 60 # in seconds
- self.auto_saver_thread = None
+ self.auto_saver_thread: typing.Optional[threading.Thread] = None
self.save_dirty = False
self.tags = ['AP']
self.games: typing.Dict[int, str] = {}
@@ -231,6 +307,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo
self.stored_data = {}
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
self.read_data = {}
+ self.spheres = []
# init empty to satisfy linter, I suppose
self.gamespackage = {}
@@ -239,6 +316,10 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo
self.location_name_groups = {}
self.all_item_and_group_names = {}
self.all_location_and_group_names = {}
+ self.item_names = collections.defaultdict(
+ lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})'))
+ self.location_names = collections.defaultdict(
+ lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})'))
self.non_hintable_names = collections.defaultdict(frozenset)
self._load_game_data()
@@ -255,19 +336,31 @@ def _load_game_data(self):
for world_name, world in worlds.AutoWorldRegister.world_types.items():
self.non_hintable_names[world_name] = world.hint_blacklist
+ for game_package in self.gamespackage.values():
+ # remove groups from data sent to clients
+ del game_package["item_name_groups"]
+ del game_package["location_name_groups"]
+
def _init_game_data(self):
for game_name, game_package in self.gamespackage.items():
if "checksum" in game_package:
self.checksums[game_name] = game_package["checksum"]
for item_name, item_id in game_package["item_name_to_id"].items():
- self.item_names[item_id] = item_name
+ self.item_names[game_name][item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items():
- self.location_names[location_id] = location_name
+ self.location_names[game_name][location_id] = location_name
self.all_item_and_group_names[game_name] = \
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
self.all_location_and_group_names[game_name] = \
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
+ archipelago_item_names = self.item_names["Archipelago"]
+ archipelago_location_names = self.location_names["Archipelago"]
+ for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]:
+ # Add Archipelago items and locations to each data package.
+ self.item_names[game].update(archipelago_item_names)
+ self.location_names[game].update(archipelago_location_names)
+
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
@@ -282,12 +375,12 @@ async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bo
try:
await endpoint.socket.send(msg)
except websockets.ConnectionClosed:
- logging.exception(f"Exception during send_msgs, could not send {msg}")
+ self.logger.exception(f"Exception during send_msgs, could not send {msg}")
await self.disconnect(endpoint)
return False
else:
if self.log_network:
- logging.info(f"Outgoing message: {msg}")
+ self.logger.info(f"Outgoing message: {msg}")
return True
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
@@ -296,12 +389,12 @@ async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
try:
await endpoint.socket.send(msg)
except websockets.ConnectionClosed:
- logging.exception("Exception during send_encoded_msgs")
+ self.logger.exception("Exception during send_encoded_msgs")
await self.disconnect(endpoint)
return False
else:
if self.log_network:
- logging.info(f"Outgoing message: {msg}")
+ self.logger.info(f"Outgoing message: {msg}")
return True
async def broadcast_send_encoded_msgs(self, endpoints: typing.Iterable[Endpoint], msg: str) -> bool:
@@ -312,26 +405,36 @@ async def broadcast_send_encoded_msgs(self, endpoints: typing.Iterable[Endpoint]
try:
websockets.broadcast(sockets, msg)
except RuntimeError:
- logging.exception("Exception during broadcast_send_encoded_msgs")
+ self.logger.exception("Exception during broadcast_send_encoded_msgs")
return False
else:
if self.log_network:
- logging.info(f"Outgoing broadcast: {msg}")
+ self.logger.info(f"Outgoing broadcast: {msg}")
return True
def broadcast_all(self, msgs: typing.List[dict]):
- msgs = self.dumper(msgs)
- endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
- async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
+ msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
+ data = self.dumper(msgs)
+ endpoints = (
+ endpoint
+ for endpoint in self.endpoints
+ if endpoint.auth and not (msg_is_text and endpoint.no_text)
+ )
+ async_start(self.broadcast_send_encoded_msgs(endpoints, data))
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
- logging.info("Notice (all): %s" % text)
+ self.logger.info("Notice (all): %s" % text)
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
def broadcast_team(self, team: int, msgs: typing.List[dict]):
- msgs = self.dumper(msgs)
- endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
- async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
+ msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
+ data = self.dumper(msgs)
+ endpoints = (
+ endpoint
+ for endpoint in itertools.chain.from_iterable(self.clients[team].values())
+ if not (msg_is_text and endpoint.no_text)
+ )
+ async_start(self.broadcast_send_encoded_msgs(endpoints, data))
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
msgs = self.dumper(msgs)
@@ -345,13 +448,13 @@ async def disconnect(self, endpoint: Client):
await on_client_disconnected(self, endpoint)
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
- if not client.auth:
+ if not client.auth or client.no_text:
return
- logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
+ self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
- if not client.auth:
+ if not client.auth or client.no_text:
return
async_start(self.send_msgs(client,
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
@@ -382,23 +485,29 @@ def decompress(data: bytes) -> dict:
raise Utils.VersionException("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:]))
- def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
+ def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any],
use_embedded_server_options: bool):
self.read_data = {}
+ # there might be a better place to put this.
+ self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > version_tuple:
- raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
+ raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, "
f"however this server is of version {version_tuple}")
self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {}
+ if self.generator_version < Version(0, 6, 2):
+ min_version = Version(0, 1, 6)
+ else:
+ min_version = min_client_version
for player, version in clients_ver.items():
- self.minimum_client_versions[player] = max(Version(*version), min_client_version)
+ self.minimum_client_versions[player] = max(Version(*version), min_version)
self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
- self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
+ self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items()
if slot_info.type == SlotType.group}
self.clients = {0: {}}
@@ -412,6 +521,8 @@ def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.A
self.player_name_lookup[slot_info.name] = 0, slot_id
self.read_data[f"hints_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
list(self.get_rechecked_hints(local_team, local_player))
+ self.read_data[f"client_status_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
+ self.client_game_state[local_team, local_player]
self.seed_name = decoded_obj["seed_name"]
self.random.seed(self.seed_name)
@@ -444,7 +555,7 @@ def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.A
for game_name, data in decoded_obj.get("datapackage", {}).items():
if game_name in game_data_packages:
data = game_data_packages[game_name]
- logging.info(f"Loading embedded data package for game {game_name}")
+ self.logger.info(f"Loading embedded data package for game {game_name}")
self.gamespackage[game_name] = data
self.item_name_groups[game_name] = data["item_name_groups"]
if "location_name_groups" in data:
@@ -457,6 +568,9 @@ def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.A
for game_name, data in self.location_name_groups.items():
self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame]
+ # sorted access spheres
+ self.spheres = decoded_obj.get("spheres", [])
+
# saving
def save(self, now=False) -> bool:
@@ -472,11 +586,12 @@ def save(self, now=False) -> bool:
def _save(self, exit_save: bool = False) -> bool:
try:
+ # Does not use Utils.restricted_dumps because we'd rather make a save than not make one
encoded_save = pickle.dumps(self.get_save())
with open(self.save_filename, "wb") as f:
f.write(zlib.compress(encoded_save))
except Exception as e:
- logging.exception(e)
+ self.logger.exception(e)
return False
else:
return True
@@ -494,12 +609,12 @@ def init_save(self, enabled: bool = True):
save_data = restricted_loads(zlib.decompress(f.read()))
self.set_save(save_data)
except FileNotFoundError:
- logging.error('No save data found, starting a new game')
+ self.logger.error('No save data found, starting a new game')
except Exception as e:
- logging.exception(e)
+ self.logger.exception(e)
self._start_async_saving()
- def _start_async_saving(self):
+ def _start_async_saving(self, atexit_save: bool = True):
if not self.auto_saver_thread:
def save_regularly():
# time.time() is platform dependent, so using the expensive datetime method instead
@@ -513,18 +628,22 @@ def get_datetime_second():
next_wakeup = (second - get_datetime_second()) % self.auto_save_interval
time.sleep(max(1.0, next_wakeup))
if self.save_dirty:
- logging.debug("Saving via thread.")
+ self.logger.debug("Saving via thread.")
self._save()
except OperationalError as e:
- logging.exception(e)
- logging.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
+ self.logger.exception(e)
+ self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
else:
self.save_dirty = False
+ if not atexit_save: # if atexit is used, that keeps a reference anyway
+ queue_gc()
+
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
self.auto_saver_thread.start()
- import atexit
- atexit.register(self._save, True) # make sure we save on exit too
+ if atexit_save:
+ import atexit
+ atexit.register(self._save, True) # make sure we save on exit too
def get_save(self) -> dict:
self.recheck_hints()
@@ -548,6 +667,7 @@ def get_save(self) -> dict:
"server_password": self.server_password, "password": self.password,
"release_mode": self.release_mode,
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
+ "countdown_mode": self.countdown_mode,
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
}
@@ -579,9 +699,10 @@ def set_save(self, savedata: dict):
self.location_check_points = savedata["game_options"]["location_check_points"]
self.server_password = savedata["game_options"]["server_password"]
self.password = savedata["game_options"]["password"]
- self.release_mode = savedata["game_options"].get("release_mode", savedata["game_options"].get("forfeit_mode", "goal"))
+ self.release_mode = savedata["game_options"]["release_mode"]
self.remaining_mode = savedata["game_options"]["remaining_mode"]
self.collect_mode = savedata["game_options"]["collect_mode"]
+ self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode)
self.item_cheat = savedata["game_options"]["item_cheat"]
self.compatibility = savedata["game_options"]["compatibility"]
@@ -591,7 +712,7 @@ def set_save(self, savedata: dict):
if "stored_data" in savedata:
self.stored_data = savedata["stored_data"]
# count items and slots from lists for items_handling = remote
- logging.info(
+ self.logger.info(
f'Loaded save file with {sum([len(v) for k, v in self.received_items.items() if k[2]])} received items '
f'for {sum(k[2] for k in self.received_items)} players')
@@ -602,18 +723,44 @@ def get_hint_cost(self, slot):
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0
- def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None):
+ def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None,
+ changed: typing.Optional[typing.Set[team_slot]] = None) -> None:
+ """Refreshes the hints for the specified team/slot. Providing 'None' for either team or slot
+ will refresh all teams or all slots respectively. If a set is passed for 'changed', each (team,slot)
+ pair that has at least one hint modified will be added to the set.
+ """
for hint_team, hint_slot in self.hints:
- if (team is None or team == hint_team) and (slot is None or slot == hint_slot):
- self.hints[hint_team, hint_slot] = {
- hint.re_check(self, hint_team) for hint in
- self.hints[hint_team, hint_slot]
- }
+ if team != hint_team and team is not None:
+ continue # Check specified team only, all if team is None
+ if slot != hint_slot and slot is not None:
+ continue # Check specified slot only, all if slot is None
+ new_hints: typing.Set[Hint] = set()
+ for hint in self.hints[hint_team, hint_slot]:
+ new_hint = hint.re_check(self, hint_team)
+ new_hints.add(new_hint)
+ if hint == new_hint:
+ continue
+ for player in self.slot_set(hint.receiving_player) | {hint.finding_player}:
+ if changed is not None:
+ changed.add((hint_team,player))
+ if slot is not None and slot != player:
+ self.replace_hint(hint_team, player, hint, new_hint)
+ self.hints[hint_team, hint_slot] = new_hints
def get_rechecked_hints(self, team: int, slot: int):
self.recheck_hints(team, slot)
return self.hints[team, slot]
+ def get_sphere(self, player: int, location_id: int) -> int:
+ """Get sphere of a location, -1 if spheres are not available."""
+ if self.spheres:
+ for i, sphere in enumerate(self.spheres):
+ if location_id in sphere.get(player, set()):
+ return i
+ raise KeyError(f"No Sphere found for location ID {location_id} belonging to player {player}. "
+ f"Location or player may not exist.")
+ return -1
+
def get_players_package(self):
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
@@ -624,8 +771,6 @@ def slot_set(self, slot) -> typing.Set[int]:
def _set_options(self, server_options: dict):
for key, value in server_options.items():
- if key == "forfeit_mode":
- key = "release_mode"
data_type = self.simple_options.get(key, None)
if data_type is not None:
if value not in {False, True, None}: # some can be boolean OR text, such as password
@@ -635,13 +780,13 @@ def _set_options(self, server_options: dict):
try:
raise Exception(f"Could not set server option {key}, skipping.") from e
except Exception as e:
- logging.exception(e)
- logging.debug(f"Setting server option {key} to {value} from supplied multidata")
+ self.logger.exception(e)
+ self.logger.debug(f"Setting server option {key} to {value} from supplied multidata")
setattr(self, key, value)
elif key == "disable_item_cheat":
self.item_cheat = not bool(value)
else:
- logging.debug(f"Unrecognized server option {key}")
+ self.logger.debug(f"Unrecognized server option {key}")
def get_aliased_name(self, team: int, slot: int):
if (team, slot) in self.name_aliases:
@@ -649,7 +794,8 @@ def get_aliased_name(self, team: int, slot: int):
else:
return self.player_names[team, slot]
- def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
+ def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
+ persist_even_if_found: bool = False, recipients: typing.Sequence[int] = None):
"""Send and remember hints."""
if only_new:
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
@@ -663,8 +809,10 @@ def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: b
concerns[player].append(data)
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
- # remember hints in all cases
- if not hint.found:
+
+ # For !hint use cases, only hints that were not already found at the time of creation should be remembered
+ # For LocationScouts use-cases, all hints should be remembered
+ if not hint.found or persist_even_if_found:
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in self.hints[team, hint.finding_player]:
@@ -674,17 +822,29 @@ def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: b
self.hints[team, player].add(hint)
new_hint_events.add(player)
- logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
+ self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
for slot in new_hint_events:
self.on_new_hint(team, slot)
for slot, hint_data in concerns.items():
- clients = self.clients[team].get(slot)
- if not clients:
- continue
- client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
- for client in clients:
- async_start(self.send_msgs(client, client_hints))
-
+ if recipients is None or slot in recipients:
+ clients = filter(lambda c: not c.no_text, self.clients[team].get(slot, []))
+ if not clients:
+ continue
+ client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
+ for client in clients:
+ async_start(self.send_msgs(client, client_hints))
+
+ def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]:
+ for hint in self.hints[team, finding_player]:
+ if hint.location == seeked_location and hint.finding_player == finding_player:
+ return hint
+ return None
+
+ def replace_hint(self, team: int, slot: int, old_hint: Hint, new_hint: Hint) -> None:
+ if old_hint in self.hints[team, slot]:
+ self.hints[team, slot].remove(old_hint)
+ self.hints[team, slot].add(new_hint)
+
# "events"
def on_goal_achieved(self, client: Client):
@@ -698,15 +858,24 @@ def on_goal_achieved(self, client: Client):
self.save() # save goal completion flag
def on_new_hint(self, team: int, slot: int):
- key: str = f"_read_hints_{team}_{slot}"
- targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
- if targets:
- self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}])
+ self.on_changed_hints(team, slot)
self.broadcast(self.clients[team][slot], [{
"cmd": "RoomUpdate",
"hint_points": get_slot_points(self, team, slot)
}])
+ def on_changed_hints(self, team: int, slot: int):
+ key: str = f"_read_hints_{team}_{slot}"
+ targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
+ if targets:
+ self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}])
+
+ def on_client_status_change(self, team: int, slot: int):
+ key: str = f"_read_client_status_{team}_{slot}"
+ targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
+ if targets:
+ self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.client_game_state[team, slot]}])
+
def update_aliases(ctx: Context, team: int):
cmd = ctx.dumper([{"cmd": "RoomUpdate",
@@ -717,27 +886,27 @@ def update_aliases(ctx: Context, team: int):
async_start(ctx.send_encoded_msgs(client, cmd))
-async def server(websocket, path: str = "/", ctx: Context = None):
+async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None:
client = Client(websocket, ctx)
ctx.endpoints.append(client)
try:
if ctx.log_network:
- logging.info("Incoming connection")
+ ctx.logger.info("Incoming connection")
await on_client_connected(ctx, client)
if ctx.log_network:
- logging.info("Sent Room Info")
+ ctx.logger.info("Sent Room Info")
async for data in websocket:
if ctx.log_network:
- logging.info(f"Incoming message: {data}")
+ ctx.logger.info(f"Incoming message: {data}")
for msg in decode(data):
await process_client_cmd(ctx, client, msg)
except Exception as e:
if not isinstance(e, websockets.WebSocketException):
- logging.exception(e)
+ ctx.logger.exception(e)
finally:
if ctx.log_network:
- logging.info("Disconnected")
+ ctx.logger.info("Disconnected")
await ctx.disconnect(client)
@@ -747,10 +916,7 @@ async def on_client_connected(ctx: Context, client: Client):
for slot, connected_clients in clients.items():
if connected_clients:
name = ctx.player_names[team, slot]
- players.append(
- NetworkPlayer(team, slot,
- ctx.name_aliases.get((team, slot), name), name)
- )
+ players.append(NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name))
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
games.add("Archipelago")
await ctx.send_msgs(client, [{
@@ -765,8 +931,6 @@ async def on_client_connected(ctx: Context, client: Client):
'permissions': get_permissions(ctx),
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points,
- 'datapackage_versions': {game: game_data["version"] for game, game_data
- in ctx.gamespackage.items() if game in games},
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
'seed_name': ctx.seed_name,
@@ -787,14 +951,25 @@ async def on_client_disconnected(ctx: Context, client: Client):
await on_client_left(ctx, client)
+_non_game_messages = {"HintGame": "hinting", "Tracker": "tracking", "TextOnly": "viewing"}
+""" { tag: ui_message } """
+
+
async def on_client_joined(ctx: Context, client: Client):
if ctx.client_game_state[client.team, client.slot] == ClientStatus.CLIENT_UNKNOWN:
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
version_str = '.'.join(str(x) for x in client.version)
- verb = "tracking" if "Tracker" in client.tags else "playing"
+
+ for tag, verb in _non_game_messages.items():
+ if tag in client.tags:
+ final_verb = verb
+ break
+ else:
+ final_verb = "playing"
+
ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
- f"{verb} {ctx.games[client.slot]} has joined. "
+ f"{final_verb} {ctx.games[client.slot]} has joined. "
f"Client({version_str}), {client.tags}.",
{"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags})
ctx.notify_client(client, "Now that you are connected, "
@@ -802,6 +977,10 @@ async def on_client_joined(ctx: Context, client: Client):
"If your client supports it, "
"you may have additional local commands you can list with /help.",
{"type": "Tutorial"})
+ if not any(isinstance(extension, PerMessageDeflate) for extension in client.socket.extensions):
+ ctx.notify_client(client, "Warning: your client does not support compressed websocket connections! "
+ "It may stop working in the future. If you are a player, please report this to the "
+ "client's developer.")
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
@@ -809,8 +988,19 @@ async def on_client_left(ctx: Context, client: Client):
if len(ctx.clients[client.team][client.slot]) < 1:
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
+
+ version_str = '.'.join(str(x) for x in client.version)
+
+ for tag, verb in _non_game_messages.items():
+ if tag in client.tags:
+ final_verb = f"stopped {verb}"
+ break
+ else:
+ final_verb = "left"
+
ctx.broadcast_text_all(
- "%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1),
+ f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has {final_verb} the game. "
+ f"Client({version_str}), {client.tags}.",
{"type": "Part", "team": client.team, "slot": client.slot})
@@ -857,9 +1047,13 @@ def get_status_string(ctx: Context, team: int, tag: str):
tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags])
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else ""
- goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
+ status_text = (
+ " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else
+ " and is ready." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_READY else
+ "."
+ )
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
- f"{tag_text}{goal_text} {completion_text}"
+ f"{tag_text}{status_text} {completion_text}"
return text
@@ -923,7 +1117,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
collect_player(ctx, team, group, True)
-def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
+def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[typing.Tuple[int, int]]:
return ctx.locations.get_remaining(ctx.location_checks, team, slot)
@@ -937,21 +1131,37 @@ def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
count_activity: bool = True):
+ slot_locations = ctx.locations[slot]
new_locations = set(locations) - ctx.location_checks[team, slot]
- new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata
+ new_locations.intersection_update(slot_locations) # ignore location IDs unknown to this multidata
if new_locations:
if count_activity:
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
+
+ sortable: list[tuple[int, int, int, int]] = []
for location in new_locations:
- item_id, target_player, flags = ctx.locations[slot][location]
+ # extract all fields to avoid runtime overhead in LocationStore
+ item_id, target_player, flags = slot_locations[location]
+ # sort/group by receiver and item
+ sortable.append((target_player, item_id, location, flags))
+
+ info_texts: list[dict[str, typing.Any]] = []
+ for target_player, item_id, location, flags in sorted(sortable):
new_item = NetworkItem(item_id, location, slot, flags)
send_items_to(ctx, team, target_player, new_item)
- logging.info('(Team #%d) %s sent %s to %s (%s)' % (
- team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
- ctx.player_names[(team, target_player)], ctx.location_names[location]))
- info_text = json_format_send_event(new_item, target_player)
- ctx.broadcast_team(team, [info_text])
+ ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
+ team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
+ ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
+ if len(info_texts) >= 140:
+ # split into chunks that are close to compression window of 64K but not too big on the wire
+ # (roughly 1300-2600 bytes after compression depending on repetitiveness)
+ ctx.broadcast_team(team, info_texts)
+ info_texts.clear()
+ info_texts.append(json_format_send_event(new_item, target_player))
+ ctx.broadcast_team(team, info_texts)
+ del info_texts
+ del sortable
ctx.location_checks[team, slot] |= new_locations
send_new_items(ctx)
@@ -960,11 +1170,20 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
"hint_points": get_slot_points(ctx, team, slot),
"checked_locations": new_locations, # send back new checks only
}])
-
+ updated_slots: typing.Set[tuple[int, int]] = set()
+ ctx.recheck_hints(team, slot, updated_slots)
+ for hint_team, hint_slot in updated_slots:
+ ctx.on_changed_hints(hint_team, hint_slot)
ctx.save()
-def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
+def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str],
+ status: HintStatus | None = None) -> typing.List[Hint]:
+ """
+ Collect a new hint for a given item id or name, with a given status.
+ If status is None (which is the default value), an automatic status will be determined from the item's quality.
+ """
+
hints = []
slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items():
@@ -974,39 +1193,84 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
for finding_player, location_id, item_id, receiving_player, item_flags \
in ctx.locations.find_item(slots, seeked_item_id):
- found = location_id in ctx.location_checks[team, finding_player]
- entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
- hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
- item_flags))
+ prev_hint = ctx.get_hint(team, finding_player, location_id)
+ if prev_hint:
+ hints.append(prev_hint)
+ else:
+ found = location_id in ctx.location_checks[team, finding_player]
+ entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
+
+ hint_status = status # Assign again because we're in a for loop
+ if found:
+ hint_status = HintStatus.HINT_FOUND
+ elif hint_status is None:
+ if item_flags & ItemClassification.trap:
+ hint_status = HintStatus.HINT_AVOID
+ else:
+ hint_status = HintStatus.HINT_PRIORITY
+
+ hints.append(
+ Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
+ )
return hints
-def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
+def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str,
+ status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
+ """
+ Collect a new hint for a given location name, with a given status (defaults to "unspecified").
+ If None is passed for the status, then an automatic status will be determined from the item's quality.
+ """
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
- return collect_hint_location_id(ctx, team, slot, seeked_location)
-
-
-def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int) -> typing.List[NetUtils.Hint]:
+ return collect_hint_location_id(ctx, team, slot, seeked_location, status)
+
+
+def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int,
+ status: HintStatus | None = HintStatus.HINT_UNSPECIFIED) -> typing.List[Hint]:
+ """
+ Collect a new hint for a given location id, with a given status (defaults to "unspecified").
+ If None is passed for the status, then an automatic status will be determined from the item's quality.
+ """
+ prev_hint = ctx.get_hint(team, slot, seeked_location)
+ if prev_hint:
+ return [prev_hint]
result = ctx.locations[slot].get(seeked_location, (None, None, None))
if any(result):
item_id, receiving_player, item_flags = result
found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
- return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags)]
+
+ if found:
+ status = HintStatus.HINT_FOUND
+ elif status is None:
+ if item_flags & ItemClassification.trap:
+ status = HintStatus.HINT_AVOID
+ else:
+ status = HintStatus.HINT_PRIORITY
+
+ return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags, status)]
return []
-def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
+status_names: typing.Dict[HintStatus, str] = {
+ HintStatus.HINT_FOUND: "(found)",
+ HintStatus.HINT_UNSPECIFIED: "(unspecified)",
+ HintStatus.HINT_NO_PRIORITY: "(no priority)",
+ HintStatus.HINT_AVOID: "(avoid)",
+ HintStatus.HINT_PRIORITY: "(priority)",
+}
+def format_hint(ctx: Context, team: int, hint: Hint) -> str:
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
- f"{ctx.item_names[hint.item]} is " \
- f"at {ctx.location_names[hint.location]} " \
+ f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
+ f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
f"in {ctx.player_names[team, hint.finding_player]}'s World"
if hint.entrance:
text += f" at {hint.entrance}"
- return text + (". (found)" if hint.found else ".")
+
+ return text + ". " + status_names.get(hint.status, "(unknown)")
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
@@ -1030,26 +1294,6 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
"item": net_item}
-def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
- picks = Utils.get_fuzzy_results(input_text, possible_answers, limit=2)
- if len(picks) > 1:
- dif = picks[0][1] - picks[1][1]
- if picks[0][1] == 100:
- return picks[0][0], True, "Perfect Match"
- elif picks[0][1] < 75:
- return picks[0][0], False, f"Didn't find something that closely matches, " \
- f"did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
- elif dif > 5:
- return picks[0][0], True, "Close Match"
- else:
- return picks[0][0], False, f"Too many close matches, did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
- else:
- if picks[0][1] > 90:
- return picks[0][0], True, "Only Option Match"
- else:
- return picks[0][0], False, f"Did you mean {picks[0][0]}? ({picks[0][1]}% sure)"
-
-
class CommandMeta(type):
def __new__(cls, name, bases, attrs):
commands = attrs["commands"] = {}
@@ -1081,7 +1325,10 @@ def __call__(self, raw: str) -> typing.Optional[bool]:
if not raw:
return
try:
- command = raw.split()
+ try:
+ command = shlex.split(raw, comments=False)
+ except ValueError: # most likely: "ValueError: No closing quotation"
+ command = raw.split()
basecommand = command[0]
if basecommand[0] == self.marker:
method = self.commands.get(basecommand[1:].lower(), None)
@@ -1117,7 +1364,8 @@ def get_help_text(self) -> str:
argname += "=" + parameter.default
argtext += argname
argtext += " "
- s += f"{self.marker}{command} {argtext}\n {method.__doc__}\n"
+ doctext = '\n '.join(inspect.getdoc(method).split('\n'))
+ s += f"{self.marker}{command} {argtext}\n {doctext}\n"
return s
def _cmd_help(self):
@@ -1146,15 +1394,6 @@ def _error_parsing_command(self, exception: Exception):
class CommonCommandProcessor(CommandProcessor):
ctx: Context
- def _cmd_countdown(self, seconds: str = "10") -> bool:
- """Start a countdown in seconds"""
- try:
- timer = int(seconds, 10)
- except ValueError:
- timer = 10
- async_start(countdown(self.ctx, timer))
- return True
-
def _cmd_options(self):
"""List all current options. Warning: lists password."""
self.output("Current options:")
@@ -1296,13 +1535,30 @@ def _cmd_collect(self) -> bool:
" You can ask the server admin for a /collect")
return False
+ def _cmd_countdown(self, seconds: str = "10") -> bool:
+ """Start a countdown in seconds"""
+ if self.ctx.countdown_mode == "disabled" or \
+ self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30:
+ self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown")
+ return False
+ try:
+ timer = int(seconds, 10)
+ except ValueError:
+ timer = 10
+ else:
+ if timer > 60 * 60:
+ raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
+
+ async_start(countdown(self.ctx, timer))
+ return True
+
def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled":
- remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
- if remaining_item_ids:
- self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
- for item_id in remaining_item_ids))
+ rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
+ if rest_locations:
+ self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
+ for slot, item_id in rest_locations))
else:
self.output("No remaining items found.")
return True
@@ -1312,10 +1568,10 @@ def _cmd_remaining(self) -> bool:
return False
else: # is goal
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
- remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
- if remaining_item_ids:
- self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
- for item_id in remaining_item_ids))
+ rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot)
+ if rest_locations:
+ self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id]
+ for slot, item_id in rest_locations))
else:
self.output("No remaining items found.")
return True
@@ -1324,6 +1580,7 @@ def _cmd_remaining(self) -> bool:
"Sorry, !remaining requires you to have beaten the game on this server")
return False
+ @mark_raw
def _cmd_missing(self, filter_text="") -> bool:
"""List all missing location checks from the server's perspective.
Can be given text, which will be used as filter."""
@@ -1331,9 +1588,14 @@ def _cmd_missing(self, filter_text="") -> bool:
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
if locations:
- names = [self.ctx.location_names[location] for location in locations]
+ game = self.ctx.slot_info[self.client.slot].game
+ names = [self.ctx.location_names[game][location] for location in locations]
if filter_text:
- names = [name for name in names if filter_text in name]
+ location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
+ if filter_text in location_groups: # location group name
+ names = [name for name in names if name in location_groups[filter_text]]
+ else:
+ names = [name for name in names if filter_text in name]
texts = [f'Missing: {name}' for name in names]
if filter_text:
texts.append(f"Found {len(locations)} missing location checks, displaying {len(names)} of them.")
@@ -1344,6 +1606,7 @@ def _cmd_missing(self, filter_text="") -> bool:
self.output("No missing location checks found.")
return True
+ @mark_raw
def _cmd_checked(self, filter_text="") -> bool:
"""List all done location checks from the server's perspective.
Can be given text, which will be used as filter."""
@@ -1351,9 +1614,14 @@ def _cmd_checked(self, filter_text="") -> bool:
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
if locations:
- names = [self.ctx.location_names[location] for location in locations]
+ game = self.ctx.slot_info[self.client.slot].game
+ names = [self.ctx.location_names[game][location] for location in locations]
if filter_text:
- names = [name for name in names if filter_text in name]
+ location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
+ if filter_text in location_groups: # location group name
+ names = [name for name in names if name in location_groups[filter_text]]
+ else:
+ names = [name for name in names if filter_text in name]
texts = [f'Checked: {name}' for name in names]
if filter_text:
texts.append(f"Found {len(locations)} done location checks, displaying {len(names)} of them.")
@@ -1411,23 +1679,26 @@ def _cmd_getitem(self, item_name: str) -> bool:
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot)
-
if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
self.ctx.hints[self.client.team, self.client.slot] = hints
- self.ctx.notify_hints(self.client.team, list(hints))
+ self.ctx.notify_hints(self.client.team, list(hints), recipients=(self.client.slot,))
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
f"You have {points_available} points.")
+ if hints and Utils.version_tuple < (0, 5, 0):
+ self.output("It was recently changed, so that the above hints are only shown to you. "
+ "If you meant to alert another player of an above hint, "
+ "please let them know of the content or to run !hint themselves.")
return True
elif input_text.isnumeric():
game = self.ctx.games[self.client.slot]
hint_id = int(input_text)
- hint_name = self.ctx.item_names[hint_id] \
- if not for_location and hint_id in self.ctx.item_names \
- else self.ctx.location_names[hint_id] \
- if for_location and hint_id in self.ctx.location_names \
+ hint_name = self.ctx.item_names[game][hint_id] \
+ if not for_location and hint_id in self.ctx.item_names[game] \
+ else self.ctx.location_names[game][hint_id] \
+ if for_location and hint_id in self.ctx.location_names[game] \
else None
if hint_name in self.ctx.non_hintable_names[game]:
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
@@ -1462,7 +1733,9 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool:
hints = []
for loc_name in self.ctx.location_name_groups[game][hint_name]:
if loc_name in self.ctx.location_names_for_game(game):
- hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name))
+ hints.extend(
+ collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)
+ )
else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
@@ -1472,15 +1745,13 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool:
if hints:
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
- old_hints = set(hints) - new_hints
- if old_hints:
- self.ctx.notify_hints(self.client.team, list(old_hints))
- if not new_hints:
- self.output("Hint was previously used, no points deducted.")
+ old_hints = list(set(hints) - new_hints)
+ if old_hints and not new_hints:
+ self.ctx.notify_hints(self.client.team, old_hints)
+ self.output("Hint was previously used, no points deducted.")
if new_hints:
found_hints = [hint for hint in new_hints if hint.found]
not_found_hints = [hint for hint in new_hints if not hint.found]
-
if not not_found_hints: # everything's been found, no need to pay
can_pay = 1000
elif cost:
@@ -1491,8 +1762,11 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool:
self.ctx.random.shuffle(not_found_hints)
# By popular vote, make hints prefer non-local placements
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
+ # By another popular vote, prefer early sphere
+ not_found_hints.sort(key=lambda hint: self.ctx.get_sphere(hint.finding_player, hint.location),
+ reverse=True)
- hints = found_hints
+ hints = found_hints + old_hints
while can_pay > 0:
if not not_found_hints:
break
@@ -1500,9 +1774,10 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool:
hints.append(hint)
can_pay -= 1
self.ctx.hints_used[self.client.team, self.client.slot] += 1
- points_available = get_client_points(self.ctx, self.client)
+ self.ctx.notify_hints(self.client.team, hints)
if not_found_hints:
+ points_available = get_client_points(self.ctx, self.client)
if hints and cost and int((points_available // cost) == 0):
self.output(
f"There may be more hintables, however, you cannot afford to pay for any more. "
@@ -1515,7 +1790,6 @@ def get_hints(self, input_text: str, for_location: bool = False) -> bool:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
- self.ctx.notify_hints(self.client.team, hints)
self.ctx.save()
return True
@@ -1570,7 +1844,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
try:
cmd: str = args["cmd"]
except:
- logging.exception(f"Could not get command from {args}")
+ ctx.logger.exception(f"Could not get command from {args}")
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd", "original_cmd": None,
"text": f"Could not get command from {args} at `cmd`"}])
raise
@@ -1596,7 +1870,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
else:
team, slot = ctx.connect_names[args['name']]
game = ctx.games[slot]
- ignore_game = ("TextOnly" in args["tags"] or "Tracker" in args["tags"]) and not args.get("game")
+
+ ignore_game = not args.get("game") and any(tag in _non_game_messages for tag in args["tags"])
+
if not ignore_game and args['game'] != game:
errors.add('InvalidGame')
minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot]
@@ -1611,7 +1887,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if ctx.compatibility == 0 and args['version'] != version_tuple:
errors.add('IncompatibleVersion')
if errors:
- logging.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
+ ctx.logger.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
await ctx.send_msgs(client, [{"cmd": "ConnectionRefused", "errors": list(errors)}])
else:
team, slot = ctx.connect_names[args['name']]
@@ -1626,7 +1902,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.clients[team][slot].append(client)
client.version = args['version']
client.tags = args['tags']
- client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
+ client.no_locations = bool(client.tags & _non_game_messages.keys())
+ # set NoText for old PopTracker clients that predate the tag to save traffic
+ client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
connected_packet = {
"cmd": "Connected",
"team": client.team, "slot": client.slot,
@@ -1698,7 +1976,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
old_tags = client.tags
client.tags = args["tags"]
if set(old_tags) != set(client.tags):
- client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
+ client.no_locations = bool(client.tags & _non_game_messages.keys())
+ client.no_text = "NoText" in client.tags or (
+ "PopTracker" in client.tags and client.version < (0, 5, 1)
+ )
ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
f"from {old_tags} to {client.tags}.",
@@ -1727,7 +2008,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
for location in args["locations"]:
if type(location) is not int:
await ctx.send_msgs(client,
- [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
+ [{'cmd': 'InvalidPacket', "type": "arguments",
+ "text": 'Locations has to be a list of integers',
"original_cmd": cmd}])
return
@@ -1735,13 +2017,114 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
locs.append(NetworkItem(target_item, location, target_player, flags))
- ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
+ ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2, persist_even_if_found=True)
if locs and create_as_hint:
ctx.save()
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
+ elif cmd == 'CreateHints':
+ location_player = args.get("player", client.slot)
+ locations = args["locations"]
+ status = args.get("status", HintStatus.HINT_UNSPECIFIED)
+
+ if not locations:
+ await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
+ "text": "CreateHints: No locations specified.", "original_cmd": cmd}])
+ return
+
+ try:
+ status = HintStatus(status)
+ except ValueError as err:
+ await ctx.send_msgs(client,
+ [{"cmd": "InvalidPacket", "type": "arguments",
+ "text": f"Unknown Status: {err}",
+ "original_cmd": cmd}])
+ return
+
+ hints = []
+
+ for location in locations:
+ if location_player != client.slot and location not in ctx.locations[location_player]:
+ error_text = (
+ "CreateHints: One or more of the locations do not exist for the specified off-world player. "
+ "Please refrain from hinting other slot's locations that you don't know contain your items."
+ )
+ await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
+ "text": error_text, "original_cmd": cmd}])
+ return
+
+ target_item, item_player, flags = ctx.locations[location_player][location]
+
+ if client.slot not in ctx.slot_set(item_player):
+ if status != HintStatus.HINT_UNSPECIFIED:
+ error_text = 'CreateHints: Must use "unspecified"/None status for items from other players.'
+ await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
+ "text": error_text, "original_cmd": cmd}])
+ return
+
+ if client.slot != location_player:
+ error_text = "CreateHints: Can only create hints for own items or own locations."
+ await ctx.send_msgs(client, [{"cmd": "InvalidPacket", "type": "arguments",
+ "text": error_text, "original_cmd": cmd}])
+ return
+
+ hints += collect_hint_location_id(ctx, client.team, location_player, location, status)
+
+ # As of writing this code, only_new=True does not update status for existing hints
+ ctx.notify_hints(client.team, hints, only_new=True, persist_even_if_found=True)
+ ctx.save()
+
+ elif cmd == 'UpdateHint':
+ location = args["location"]
+ player = args["player"]
+ status = args["status"]
+ if not isinstance(player, int) or not isinstance(location, int) \
+ or (status is not None and not isinstance(status, int)):
+ await ctx.send_msgs(client,
+ [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint',
+ "original_cmd": cmd}])
+ return
+ hint = ctx.get_hint(client.team, player, location)
+ if not hint:
+ return # Ignored safely
+ if client.slot not in ctx.slot_set(hint.receiving_player):
+ await ctx.send_msgs(client,
+ [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint: No Permission',
+ "original_cmd": cmd}])
+ return
+ new_hint = hint
+ if status is None:
+ return
+ try:
+ status = HintStatus(status)
+ except ValueError:
+ await ctx.send_msgs(client,
+ [{'cmd': 'InvalidPacket', "type": "arguments",
+ "text": 'UpdateHint: Invalid Status', "original_cmd": cmd}])
+ return
+ if status == HintStatus.HINT_FOUND:
+ await ctx.send_msgs(client,
+ [{'cmd': 'InvalidPacket', "type": "arguments",
+ "text": 'UpdateHint: Cannot manually update status to "HINT_FOUND"', "original_cmd": cmd}])
+ return
+ new_hint = new_hint.re_prioritize(ctx, status)
+ if hint == new_hint:
+ return
+
+ concerning_slots = ctx.slot_set(hint.receiving_player) | {hint.finding_player}
+ for slot in concerning_slots:
+ ctx.replace_hint(client.team, slot, hint, new_hint)
+ ctx.save()
+ for slot in concerning_slots:
+ ctx.on_changed_hints(client.team, slot)
+
elif cmd == 'StatusUpdate':
- update_client_status(ctx, client, args["status"])
+ if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL:
+ await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
+ "text": "Trackers can't register Goal Complete",
+ "original_cmd": cmd}])
+ else:
+ update_client_status(ctx, client, args["status"])
elif cmd == 'Say':
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
@@ -1787,12 +2170,13 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
args["cmd"] = "SetReply"
value = ctx.stored_data.get(args["key"], args.get("default", 0))
args["original_value"] = copy.copy(value)
+ args["slot"] = client.slot
for operation in args["operations"]:
func = modify_functions[operation["operation"]]
value = func(value, operation["value"])
ctx.stored_data[args["key"]] = args["value"] = value
targets = set(ctx.stored_data_notification_clients[args["key"]])
- if args.get("want_reply", True):
+ if args.get("want_reply", False):
targets.add(client)
if targets:
ctx.broadcast(targets, [args])
@@ -1812,8 +2196,14 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
if current != ClientStatus.CLIENT_GOAL: # can't undo goal completion
if new_status == ClientStatus.CLIENT_GOAL:
ctx.on_goal_achieved(client)
+ # if player has yet to ever connect to the server, they will not be in client_game_state
+ if all(player in ctx.client_game_state and ctx.client_game_state[player] == ClientStatus.CLIENT_GOAL
+ for player in ctx.player_names
+ if player[0] == client.team and player[1] != client.slot):
+ ctx.broadcast_text_all(f"Team #{client.team + 1} has completed all of their games! Congratulations!")
ctx.client_game_state[client.team, client.slot] = new_status
+ ctx.on_client_status_change(client.team, client.slot)
ctx.save()
@@ -1855,16 +2245,16 @@ def _cmd_status(self, tag: str = "") -> bool:
def _cmd_exit(self) -> bool:
"""Shutdown the server"""
- self.ctx.server.ws_server.close()
- if self.ctx.shutdown_task:
- self.ctx.shutdown_task.cancel()
- self.ctx.exit_event.set()
+ try:
+ self.ctx.server.ws_server.close()
+ finally:
+ self.ctx.exit_event.set()
return True
@mark_raw
def _cmd_alias(self, player_name_then_alias_name):
"""Set a player's alias, by listing their base name and then their intended alias."""
- player_name, alias_name = player_name_then_alias_name.split(" ", 1)
+ player_name, _, alias_name = player_name_then_alias_name.partition(" ")
player_name, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
for (team, slot), name in self.ctx.player_names.items():
@@ -1917,6 +2307,19 @@ def _cmd_collect(self, player_name: str) -> bool:
self.output(f"Could not find player {player_name} to collect")
return False
+ def _cmd_countdown(self, seconds: str = "10") -> bool:
+ """Start a countdown in seconds"""
+ try:
+ timer = int(seconds, 10)
+ except ValueError:
+ timer = 10
+ else:
+ if timer > 60 * 60:
+ raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
+
+ async_start(countdown(self.ctx, timer))
+ return True
+
@mark_raw
def _cmd_release(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients."""
@@ -1944,7 +2347,7 @@ def _cmd_allow_release(self, player_name: str) -> bool:
@mark_raw
def _cmd_forbid_release(self, player_name: str) -> bool:
- """"Disallow the specified player from using the !release command."""
+ """Disallow the specified player from using the !release command."""
player = self.resolve_player(player_name)
if player:
team, slot, name = player
@@ -1965,6 +2368,8 @@ def _cmd_send_multiple(self, amount: typing.Union[int, str], player_name: str, *
item_name, usable, response = get_intended_text(item_name, names)
if usable:
amount: int = int(amount)
+ if amount > 100:
+ raise ValueError(f"{amount} is invalid. Maximum is 100.")
new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
send_items_to(self.ctx, team, slot, *new_items)
@@ -2064,8 +2469,8 @@ def _cmd_hint_location(self, player_name: str, *location_name: str) -> bool:
if full_name.isnumeric():
location, usable, response = int(full_name), True, None
- elif self.ctx.location_names_for_game(game) is not None:
- location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game))
+ elif game in self.ctx.all_location_and_group_names:
+ location, usable, response = get_intended_text(full_name, self.ctx.all_location_and_group_names[game])
else:
self.output("Can't look up location for unknown game. Hint for ID instead.")
return False
@@ -2073,6 +2478,11 @@ def _cmd_hint_location(self, player_name: str, *location_name: str) -> bool:
if usable:
if isinstance(location, int):
hints = collect_hint_location_id(self.ctx, team, slot, location)
+ elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
+ hints = []
+ for loc_name_from_group in self.ctx.location_name_groups[game][location]:
+ if loc_name_from_group in self.ctx.location_names_for_game(game):
+ hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
else:
hints = collect_hint_location_name(self.ctx, team, slot, location)
if hints:
@@ -2088,32 +2498,52 @@ def _cmd_hint_location(self, player_name: str, *location_name: str) -> bool:
self.output(response)
return False
- def _cmd_option(self, option_name: str, option: str):
- """Set options for the server."""
-
- attrtype = self.ctx.simple_options.get(option_name, None)
- if attrtype:
- if attrtype == bool:
- def attrtype(input_text: str):
- return input_text.lower() not in {"off", "0", "false", "none", "null", "no"}
- elif attrtype == str and option_name.endswith("password"):
- def attrtype(input_text: str):
- if input_text.lower() in {"null", "none", '""', "''"}:
- return None
- return input_text
- setattr(self.ctx, option_name, attrtype(option))
- self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
- if option_name in {"release_mode", "remaining_mode", "collect_mode"}:
- self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
- elif option_name in {"hint_cost", "location_check_points"}:
- self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
- return True
- else:
- known = (f"{option}:{otype}" for option, otype in self.ctx.simple_options.items())
- self.output(f"Unrecognized Option {option_name}, known: "
- f"{', '.join(known)}")
+ def _cmd_option(self, option_name: str, option_value: str):
+ """Set an option for the server."""
+ value_type = self.ctx.simple_options.get(option_name, None)
+ if not value_type:
+ known_options = (f"{option}: {option_type}" for option, option_type in self.ctx.simple_options.items())
+ self.output(f"Unrecognized option '{option_name}', known: {', '.join(known_options)}")
return False
+ if value_type == bool:
+ def value_type(input_text: str):
+ return input_text.lower() not in {"off", "0", "false", "none", "null", "no"}
+ elif value_type == str and option_name.endswith("password"):
+ def value_type(input_text: str):
+ return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
+ elif option_name == "countdown_mode":
+ valid_values = {"enabled", "disabled", "auto"}
+ if option_value.lower() not in valid_values:
+ self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
+ return False
+ elif value_type == str and option_name.endswith("mode"):
+ valid_values = {"goal", "enabled", "disabled"}
+ valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
+ if option_value.lower() not in valid_values:
+ self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
+ return False
+
+ setattr(self.ctx, option_name, value_type(option_value))
+ self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
+ if option_name in {"release_mode", "remaining_mode", "collect_mode"}:
+ self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
+ elif option_name in {"hint_cost", "location_check_points"}:
+ self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
+ return True
+
+ def _cmd_datastore(self):
+ """Debug Tool: list writable datastorage keys and approximate the size of their values with pickle."""
+ total: int = 0
+ texts = []
+ for key, value in self.ctx.stored_data.items():
+ size = len(pickle.dumps(value))
+ total += size
+ texts.append(f"Key: {key} | Size: {size}B")
+ texts.insert(0, f"Found {len(self.ctx.stored_data)} keys, "
+ f"approximately totaling {Utils.format_SI_prefix(total, power=1024)}B")
+ self.output("\n".join(texts))
+
async def console(ctx: Context):
import sys
@@ -2136,8 +2566,10 @@ async def console(ctx: Context):
def parse_args() -> argparse.Namespace:
+ from settings import get_settings
+
parser = argparse.ArgumentParser()
- defaults = Utils.get_options()["server_options"].as_dict()
+ defaults = get_settings().server_options.as_dict()
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
parser.add_argument('--host', default=defaults["host"])
parser.add_argument('--port', default=defaults["port"], type=int)
@@ -2149,6 +2581,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
parser.add_argument('--loglevel', default=defaults["loglevel"],
choices=['debug', 'info', 'warning', 'error', 'critical'])
+ parser.add_argument('--logtime', help="Add timestamps to STDOUT",
+ default=defaults["logtime"], action='store_true')
parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int)
parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int)
parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
@@ -2170,6 +2604,13 @@ def parse_args() -> argparse.Namespace:
goal: !collect can be used after goal completion
auto-enabled: !collect is available and automatically triggered on goal completion
''')
+ parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?',
+ choices=['enabled', 'disabled', "auto"], help='''\
+ Select !countdown Accessibility. (default: %(default)s)
+ enabled: !countdown is always available
+ disabled: !countdown is never available
+ auto: !countdown is available for rooms with less than 30 players
+ ''')
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
choices=['enabled', 'disabled', "goal"], help='''\
Select !remaining Accessibility. (default: %(default)s)
@@ -2195,28 +2636,29 @@ def parse_args() -> argparse.Namespace:
async def auto_shutdown(ctx, to_cancel=None):
- await asyncio.sleep(ctx.auto_shutdown)
+ with contextlib.suppress(asyncio.TimeoutError):
+ await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown)
+
+ def inactivity_shutdown():
+ ctx.server.ws_server.close()
+ ctx.exit_event.set()
+ if to_cancel:
+ for task in to_cancel:
+ task.cancel()
+ ctx.logger.info("Shutting down due to inactivity.")
+
while not ctx.exit_event.is_set():
if not ctx.client_activity_timers.values():
- ctx.server.ws_server.close()
- ctx.exit_event.set()
- if to_cancel:
- for task in to_cancel:
- task.cancel()
- logging.info("Shutting down due to inactivity.")
+ inactivity_shutdown()
else:
newest_activity = max(ctx.client_activity_timers.values())
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
seconds = ctx.auto_shutdown - delta.total_seconds()
if seconds < 0:
- ctx.server.ws_server.close()
- ctx.exit_event.set()
- if to_cancel:
- for task in to_cancel:
- task.cancel()
- logging.info("Shutting down due to inactivity.")
+ inactivity_shutdown()
else:
- await asyncio.sleep(seconds)
+ with contextlib.suppress(asyncio.TimeoutError):
+ await asyncio.wait_for(ctx.exit_event.wait(), seconds)
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":
@@ -2228,11 +2670,13 @@ def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLConte
async def main(args: argparse.Namespace):
- Utils.init_logging("Server", loglevel=args.loglevel.lower())
+ Utils.init_logging(name="Server",
+ loglevel=args.loglevel.lower(),
+ add_timestamp=args.logtime)
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
- args.remaining_mode,
+ args.countdown_mode, args.remaining_mode,
args.auto_shutdown, args.compatibility, args.log_network)
data_filename = args.multidata
@@ -2267,7 +2711,13 @@ async def main(args: argparse.Namespace):
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
- ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context)
+ ctx.server = websockets.serve(
+ functools.partial(server, ctx=ctx),
+ host=ctx.host,
+ port=ctx.port,
+ ssl=ssl_context,
+ extensions=[server_per_message_deflate_factory],
+ )
ip = args.host if args.host else Utils.get_public_ipv4()
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
'No password' if not ctx.password else 'Password: %s' % ctx.password))
diff --git a/NetUtils.py b/NetUtils.py
index c31aa695104c..f61dbf9fcb0f 100644
--- a/NetUtils.py
+++ b/NetUtils.py
@@ -1,15 +1,25 @@
from __future__ import annotations
+from collections.abc import Mapping, Sequence
import typing
import enum
import warnings
from json import JSONEncoder, JSONDecoder
-import websockets
+if typing.TYPE_CHECKING:
+ from websockets import WebSocketServerProtocol as ServerConnection
from Utils import ByValue, Version
+class HintStatus(ByValue, enum.IntEnum):
+ HINT_UNSPECIFIED = 0
+ HINT_NO_PRIORITY = 10
+ HINT_AVOID = 20
+ HINT_PRIORITY = 30
+ HINT_FOUND = 40
+
+
class JSONMessagePart(typing.TypedDict, total=False):
text: str
# optional
@@ -19,6 +29,8 @@ class JSONMessagePart(typing.TypedDict, total=False):
player: int
# if type == item indicates item flags
flags: int
+ # if type == hint_status
+ hint_status: HintStatus
class ClientStatus(ByValue, enum.IntEnum):
@@ -72,13 +84,14 @@ class NetworkSlot(typing.NamedTuple):
name: str
game: str
type: SlotType
- group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
+ group_members: Sequence[int] = () # only populated if type == group
class NetworkItem(typing.NamedTuple):
item: int
location: int
player: int
+ """ Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """
flags: int = 0
@@ -94,6 +107,27 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
return obj
+_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"]
+
+
+def convert_to_base_types(obj: typing.Any) -> _base_types:
+ if isinstance(obj, (tuple, list, set, frozenset)):
+ return tuple(convert_to_base_types(o) for o in obj)
+ elif isinstance(obj, dict):
+ return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()}
+ elif obj is None or type(obj) in (str, int, float, bool):
+ return obj
+ # unwrap simple types to their base, such as StrEnum
+ elif isinstance(obj, str):
+ return str(obj)
+ elif isinstance(obj, int):
+ return int(obj)
+ elif isinstance(obj, float):
+ return float(obj)
+ else:
+ raise Exception(f"Cannot handle {type(obj)}")
+
+
_encode = JSONEncoder(
ensure_ascii=False,
check_circular=False,
@@ -140,7 +174,9 @@ def _object_hook(o: typing.Any) -> typing.Any:
class Endpoint:
- socket: websockets.WebSocketServerProtocol
+ __slots__ = ("socket",)
+
+ socket: "ServerConnection"
def __init__(self, socket):
self.socket = socket
@@ -183,6 +219,7 @@ class JSONTypes(str, enum.Enum):
location_name = "location_name"
location_id = "location_id"
entrance_name = "entrance_name"
+ hint_status = "hint_status"
class JSONtoTextParser(metaclass=HandlerMeta):
@@ -198,7 +235,8 @@ class JSONtoTextParser(metaclass=HandlerMeta):
"slateblue": "6D8BE8",
"plum": "AF99EF",
"salmon": "FA8072",
- "white": "FFFFFF"
+ "white": "FFFFFF",
+ "orange": "FF7700",
}
def __init__(self, ctx):
@@ -222,7 +260,7 @@ def _handle_text(self, node: JSONMessagePart):
def _handle_player_id(self, node: JSONMessagePart):
player = int(node["text"])
- node["color"] = 'magenta' if player == self.ctx.slot else 'yellow'
+ node["color"] = 'magenta' if self.ctx.slot_concerns_self(player) else 'yellow'
node["text"] = self.ctx.player_names[player]
return self._handle_color(node)
@@ -247,7 +285,7 @@ def _handle_item_name(self, node: JSONMessagePart):
def _handle_item_id(self, node: JSONMessagePart):
item_id = int(node["text"])
- node["text"] = self.ctx.item_names[item_id]
+ node["text"] = self.ctx.item_names.lookup_in_slot(item_id, node["player"])
return self._handle_item_name(node)
def _handle_location_name(self, node: JSONMessagePart):
@@ -255,14 +293,18 @@ def _handle_location_name(self, node: JSONMessagePart):
return self._handle_color(node)
def _handle_location_id(self, node: JSONMessagePart):
- item_id = int(node["text"])
- node["text"] = self.ctx.location_names[item_id]
+ location_id = int(node["text"])
+ node["text"] = self.ctx.location_names.lookup_in_slot(location_id, node["player"])
return self._handle_location_name(node)
def _handle_entrance_name(self, node: JSONMessagePart):
node["color"] = 'blue'
return self._handle_color(node)
+ def _handle_hint_status(self, node: JSONMessagePart):
+ node["color"] = status_colors.get(node["hint_status"], "red")
+ return self._handle_color(node)
+
class RawJSONtoTextParser(JSONtoTextParser):
def _handle_color(self, node: JSONMessagePart):
@@ -271,7 +313,8 @@ def _handle_color(self, node: JSONMessagePart):
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
- 'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
+ 'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47,
+ 'plum': 35, 'slateblue': 34, 'salmon': 31,} # convert ui colors to terminal colors
def color_code(*args):
@@ -290,8 +333,29 @@ def add_json_item(parts: list, item_id: int, player: int = 0, item_flags: int =
parts.append({"text": str(item_id), "player": player, "flags": item_flags, "type": JSONTypes.item_id, **kwargs})
-def add_json_location(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
- parts.append({"text": str(item_id), "player": player, "type": JSONTypes.location_id, **kwargs})
+def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs) -> None:
+ parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
+
+
+status_names: typing.Dict[HintStatus, str] = {
+ HintStatus.HINT_FOUND: "(found)",
+ HintStatus.HINT_UNSPECIFIED: "(unspecified)",
+ HintStatus.HINT_NO_PRIORITY: "(no priority)",
+ HintStatus.HINT_AVOID: "(avoid)",
+ HintStatus.HINT_PRIORITY: "(priority)",
+}
+status_colors: typing.Dict[HintStatus, str] = {
+ HintStatus.HINT_FOUND: "green",
+ HintStatus.HINT_UNSPECIFIED: "white",
+ HintStatus.HINT_NO_PRIORITY: "slateblue",
+ HintStatus.HINT_AVOID: "salmon",
+ HintStatus.HINT_PRIORITY: "plum",
+}
+
+
+def add_json_hint_status(parts: list, hint_status: HintStatus, text: typing.Optional[str] = None, **kwargs):
+ parts.append({"text": text if text != None else status_names.get(hint_status, "(unknown)"),
+ "hint_status": hint_status, "type": JSONTypes.hint_status, **kwargs})
class Hint(typing.NamedTuple):
@@ -302,14 +366,21 @@ class Hint(typing.NamedTuple):
found: bool
entrance: str = ""
item_flags: int = 0
+ status: HintStatus = HintStatus.HINT_UNSPECIFIED
def re_check(self, ctx, team) -> Hint:
- if self.found:
+ if self.found and self.status == HintStatus.HINT_FOUND:
return self
found = self.location in ctx.location_checks[team, self.finding_player]
if found:
- return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance,
- self.item_flags)
+ return self._replace(found=found, status=HintStatus.HINT_FOUND)
+ return self
+
+ def re_prioritize(self, ctx, status: HintStatus) -> Hint:
+ if self.found and status != HintStatus.HINT_FOUND:
+ status = HintStatus.HINT_FOUND
+ if status != self.status:
+ return self._replace(status=status)
return self
def __hash__(self):
@@ -331,10 +402,7 @@ def as_network_message(self) -> dict:
else:
add_json_text(parts, "'s World")
add_json_text(parts, ". ")
- if self.found:
- add_json_text(parts, "(found)", type="color", color="green")
- else:
- add_json_text(parts, "(not found)", type="color", color="red")
+ add_json_hint_status(parts, self.status)
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
"receiving": self.receiving_player,
@@ -380,6 +448,8 @@ def get_checked(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]
checked = state[team, slot]
if not checked:
# This optimizes the case where everyone connects to a fresh game at the same time.
+ if slot not in self:
+ raise KeyError(slot)
return []
return [location_id for
location_id in self[slot] if
@@ -396,25 +466,69 @@ def get_missing(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]
location_id not in checked]
def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int
- ) -> typing.List[int]:
+ ) -> typing.List[typing.Tuple[int, int]]:
checked = state[team, slot]
player_locations = self[slot]
- return sorted([player_locations[location_id][0] for
- location_id in player_locations if
- location_id not in checked])
+ return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for
+ location_id in player_locations if
+ location_id not in checked])
+
+
+class MinimumVersions(typing.TypedDict):
+ server: tuple[int, int, int]
+ clients: dict[int, tuple[int, int, int]]
+
+
+class GamesPackage(typing.TypedDict, total=False):
+ item_name_groups: dict[str, list[str]]
+ item_name_to_id: dict[str, int]
+ location_name_groups: dict[str, list[str]]
+ location_name_to_id: dict[str, int]
+ checksum: str
+
+
+class DataPackage(typing.TypedDict):
+ games: dict[str, GamesPackage]
+
+
+class MultiData(typing.TypedDict):
+ slot_data: dict[int, Mapping[str, typing.Any]]
+ slot_info: dict[int, NetworkSlot]
+ connect_names: dict[str, tuple[int, int]]
+ locations: dict[int, dict[int, tuple[int, int, int]]]
+ checks_in_area: dict[int, dict[str, int | list[int]]]
+ server_options: dict[str, object]
+ er_hint_data: dict[int, dict[int, str]]
+ precollected_items: dict[int, list[int]]
+ precollected_hints: dict[int, set[Hint]]
+ version: tuple[int, int, int]
+ tags: list[str]
+ minimum_versions: MinimumVersions
+ seed_name: str
+ spheres: list[dict[int, set[int]]]
+ datapackage: dict[str, GamesPackage]
+ race_mode: int
if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub
LocationStore = _LocationStore
else:
- try:
- import pyximport
- pyximport.install()
- except ImportError:
- pyximport = None
try:
from _speedups import LocationStore
+ import _speedups
+ import os.path
+ if os.path.isfile("_speedups.pyx") and os.path.getctime(_speedups.__file__) < os.path.getctime("_speedups.pyx"):
+ warnings.warn(f"{_speedups.__file__} outdated! "
+ f"Please rebuild with `cythonize -b -i _speedups.pyx` or delete it!")
except ImportError:
- warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
- "Install a matching C++ compiler for your platform to compile _speedups.")
- LocationStore = _LocationStore
+ try:
+ import pyximport
+ pyximport.install()
+ except ImportError:
+ pyximport = None
+ try:
+ from _speedups import LocationStore
+ except ImportError:
+ warnings.warn("_speedups not available. Falling back to pure python LocationStore. "
+ "Install a matching C++ compiler for your platform to compile _speedups.")
+ LocationStore = _LocationStore
diff --git a/OoTAdjuster.py b/OoTAdjuster.py
index 38ebe62e2ae1..1581d6539825 100644
--- a/OoTAdjuster.py
+++ b/OoTAdjuster.py
@@ -1,7 +1,6 @@
import tkinter as tk
import argparse
import logging
-import random
import os
import zipfile
from itertools import chain
@@ -195,10 +194,9 @@ def set_icon(window):
window.tk.call('wm', 'iconphoto', window._w, logo)
def adjust(args):
- # Create a fake world and OOTWorld to use as a base
- world = MultiWorld(1)
- world.per_slot_randoms = {1: random}
- ootworld = OOTWorld(world, 1)
+ # Create a fake multiworld and OOTWorld to use as a base
+ multiworld = MultiWorld(1)
+ ootworld = OOTWorld(multiworld, 1)
# Set options in the fake OOTWorld
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
result = getattr(args, name, None)
diff --git a/OoTClient.py b/OoTClient.py
index 115490417334..2b0c7e4966f0 100644
--- a/OoTClient.py
+++ b/OoTClient.py
@@ -12,6 +12,7 @@
import Utils
from Utils import async_start
from worlds import network_data_package
+from worlds.oot import OOTWorld
from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file
from worlds.oot.Utils import data_path
@@ -276,11 +277,12 @@ async def n64_sync_task(ctx: OoTContext):
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.n64_status = CONNECTION_REFUSED_STATUS
+ await asyncio.sleep(1)
continue
async def run_game(romfile):
- auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
+ auto_start = OOTWorld.settings.rom_start
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
@@ -295,7 +297,7 @@ async def patch_and_run_game(apz5_file):
decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM
- rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
+ rom_file_name = OOTWorld.settings.rom_file
rom = Rom(rom_file_name)
sub_file = None
@@ -346,7 +348,7 @@ async def main():
import colorama
- colorama.init()
+ colorama.just_fix_windows_console()
asyncio.run(main())
colorama.deinit()
diff --git a/Options.py b/Options.py
index 960e6c19d1ad..c37d0cee2810 100644
--- a/Options.py
+++ b/Options.py
@@ -1,23 +1,48 @@
from __future__ import annotations
import abc
+import collections
+import functools
import logging
import math
import numbers
import random
import typing
+import enum
+from collections import defaultdict
from copy import deepcopy
+from dataclasses import dataclass
from schema import And, Optional, Or, Schema
+from typing_extensions import Self
-from Utils import get_fuzzy_results
+from Utils import get_file_safe_name, get_fuzzy_results, is_iterable_except_str, output_path
if typing.TYPE_CHECKING:
- from BaseClasses import PlandoOptions
+ from BaseClasses import MultiWorld, PlandoOptions
from worlds.AutoWorld import World
import pathlib
+def roll_percentage(percentage: int | float) -> bool:
+ """Roll a percentage chance.
+ percentage is expected to be in range [0, 100]"""
+ return random.random() < (float(percentage) / 100)
+
+
+class OptionError(ValueError):
+ pass
+
+
+class Visibility(enum.IntFlag):
+ none = 0b0000
+ template = 0b0001
+ simple_ui = 0b0010 # show option in simple menus, such as player-options
+ complex_ui = 0b0100 # show option in complex menus, such as weighted-options
+ spoiler = 0b1000
+ all = 0b1111
+
+
class AssembleOptions(abc.ABCMeta):
def __new__(mcs, name, bases, attrs):
options = attrs["options"] = {}
@@ -36,9 +61,14 @@ def __new__(mcs, name, bases, attrs):
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
options.update(new_options)
# apply aliases, without name_lookup
- aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
- name.startswith("alias_")}
-
+ aliases = attrs["aliases"] = {name[6:].lower(): option_id for name, option_id in attrs.items() if
+ name.startswith("alias_")}
+
+ assert (
+ name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
+ "default" in attrs or
+ any(hasattr(base, "default") for base in bases)
+ ), f"Option class {name} needs default value"
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
# auto-alias Off and On being parsed as True and False
@@ -56,6 +86,7 @@ def __new__(mcs, name, bases, attrs):
def verify(self, *args, **kwargs) -> None:
for f in verifiers:
f(self, *args, **kwargs)
+
attrs["verify"] = verify
else:
assert verifiers, "class Option is supposed to implement def verify"
@@ -93,7 +124,8 @@ def meta__init__(self, *args, **kwargs):
class Option(typing.Generic[T], metaclass=AssembleOptions):
value: T
- default = 0
+ default: typing.ClassVar[typing.Any] # something that __init__ will be able to convert to the correct type
+ visibility = Visibility.all
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
# Handled in get_option_name()
@@ -102,9 +134,28 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
# can be weighted between selections
supports_weighting = True
+ rich_text_doc: typing.Optional[bool] = None
+ """Whether the WebHost should render the Option's docstring as rich text.
+
+ If this is True, the Option's docstring is interpreted as reStructuredText_,
+ the standard Python markup format. In the WebHost, it's rendered to HTML so
+ that lists, emphasis, and other rich text features are displayed properly.
+
+ If this is False, the docstring is instead interpreted as plain text, and
+ displayed as-is on the WebHost with whitespace preserved.
+
+ If this is None, it inherits the value of `WebWorld.rich_text_options_doc`. For
+ backwards compatibility, this defaults to False, but worlds are encouraged to
+ set it to True and use reStructuredText for their Option documentation.
+
+ .. _reStructuredText: https://docutils.sourceforge.io/rst.html
+ """
+
# filled by AssembleOptions:
- name_lookup: typing.Dict[T, str]
- options: typing.Dict[str, int]
+ name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
+ # https://github.com/python/typing/discussions/1460 the reason for this type: ignore
+ options: typing.ClassVar[typing.Dict[str, int]]
+ aliases: typing.ClassVar[typing.Dict[str, int]]
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.current_option_name})"
@@ -116,12 +167,6 @@ def __hash__(self) -> int:
def current_key(self) -> str:
return self.name_lookup[self.value]
- def get_current_option_name(self) -> str:
- """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."""
@@ -157,6 +202,8 @@ class FreeText(Option[str]):
"""Text option that allows users to enter strings.
Needs to be validated by the world or option definition."""
+ default = ""
+
def __init__(self, value: str):
assert isinstance(value, str), "value of FreeText must be a string"
self.value = value
@@ -177,9 +224,18 @@ def from_any(cls, data: typing.Any) -> FreeText:
def get_option_name(cls, value: str) -> str:
return value
+ def __eq__(self, other):
+ if isinstance(other, self.__class__):
+ return other.value == self.value
+ elif isinstance(other, str):
+ return other == self.value
+ else:
+ raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
+
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
# (even though isinstance(5, numbers.Integral) == True)
@@ -211,6 +267,12 @@ def __gt__(self, other: typing.Union[int, NumericOption]) -> bool:
else:
return self.value > other
+ def __ge__(self, other: typing.Union[int, NumericOption]) -> bool:
+ if isinstance(other, NumericOption):
+ return self.value >= other.value
+ else:
+ return self.value >= other
+
def __bool__(self) -> bool:
return bool(self.value)
@@ -347,7 +409,8 @@ class Toggle(NumericOption):
default = 0
def __init__(self, value: int):
- assert value == 0 or value == 1, "value of Toggle can only be 0 or 1"
+ # if user puts in an invalid value, make it valid
+ value = int(bool(value))
self.value = value
@classmethod
@@ -431,6 +494,30 @@ def __ne__(self, other):
else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
+ def __lt__(self, other: typing.Union[Choice, int, str]):
+ if isinstance(other, str):
+ assert other in self.options, f"compared against an unknown string. {self} < {other}"
+ other = self.options[other]
+ return super(Choice, self).__lt__(other)
+
+ def __gt__(self, other: typing.Union[Choice, int, str]):
+ if isinstance(other, str):
+ assert other in self.options, f"compared against an unknown string. {self} > {other}"
+ other = self.options[other]
+ return super(Choice, self).__gt__(other)
+
+ def __le__(self, other: typing.Union[Choice, int, str]):
+ if isinstance(other, str):
+ assert other in self.options, f"compared against an unknown string. {self} <= {other}"
+ other = self.options[other]
+ return super(Choice, self).__le__(other)
+
+ def __ge__(self, other: typing.Union[Choice, int, str]):
+ if isinstance(other, str):
+ assert other in self.options, f"compared against an unknown string. {self} >= {other}"
+ other = self.options[other]
+ return super(Choice, self).__ge__(other)
+
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
@@ -440,7 +527,7 @@ class TextChoice(Choice):
def __init__(self, value: typing.Union[str, int]):
assert isinstance(value, str) or isinstance(value, int), \
- f"{value} is not a valid option for {self.__class__.__name__}"
+ f"'{value}' is not a valid option for '{self.__class__.__name__}'"
self.value = value
@property
@@ -561,17 +648,17 @@ def validate_plando_bosses(cls, options: typing.List[str]) -> None:
used_locations.append(location)
used_bosses.append(boss)
if not cls.valid_boss_name(boss):
- raise ValueError(f"{boss.title()} is not a valid boss name.")
+ raise ValueError(f"'{boss.title()}' is not a valid boss name.")
if not cls.valid_location_name(location):
- raise ValueError(f"{location.title()} is not a valid boss location name.")
+ raise ValueError(f"'{location.title()}' is not a valid boss location name.")
if not cls.can_place_boss(boss, location):
- raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
+ raise ValueError(f"'{location.title()}' is not a valid location for {boss.title()} to be placed.")
else:
if cls.duplicate_bosses:
if not cls.valid_boss_name(option):
- raise ValueError(f"{option} is not a valid boss name.")
+ raise ValueError(f"'{option}' is not a valid boss name.")
else:
- raise ValueError(f"{option.title()} is not formatted correctly.")
+ raise ValueError(f"'{option.title()}' is not formatted correctly.")
@classmethod
def can_place_boss(cls, boss: str, location: str) -> bool:
@@ -589,7 +676,7 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
if isinstance(self.value, int):
return
from BaseClasses import PlandoOptions
- if not(PlandoOptions.bosses & plando_options):
+ 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]
@@ -601,6 +688,12 @@ class Range(NumericOption):
range_start = 0
range_end = 1
+ _RANDOM_OPTS = [
+ "random", "random-low", "random-middle", "random-high",
+ "random-range-low--", "random-range-middle--",
+ "random-range-high--", "random-range--",
+ ]
+
def __init__(self, value: int):
if value < self.range_start:
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__}")
@@ -626,16 +719,33 @@ def from_text(cls, text: str) -> Range:
# these are the conditions where "true" and "false" make sense
if text == "true":
return cls.from_any(cls.default)
- else: # "false"
- return cls(0)
- return cls(int(text))
+ # "false"
+ return cls(0)
+
+ try:
+ num = int(text)
+ except ValueError:
+ # text is not a number
+ # Handle conditionally acceptable values here rather than in the f-string
+ default = ""
+ truefalse = ""
+ if hasattr(cls, "default"):
+ default = ", default"
+ if cls.range_start == 0 and cls.default != 0:
+ truefalse = ", \"true\", \"false\""
+ raise Exception(f"Invalid range value {text!r}. Acceptable values are: "
+ f"{default}, high, low{truefalse}, "
+ f"{', '.join(cls._RANDOM_OPTS)}.")
+
+ return cls(num)
+
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
- return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
+ return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
elif text == "random-high":
- return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
+ return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
elif text == "random-middle":
return cls(cls.triangular(cls.range_start, cls.range_end))
elif text.startswith("random-range-"):
@@ -644,9 +754,7 @@ def weighted_range(cls, text) -> Range:
return cls(random.randint(cls.range_start, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
- f"Acceptable values are: random, random-high, random-middle, random-low, "
- f"random-range-low--, random-range-middle--, "
- f"random-range-high--, or random-range--.")
+ f"Acceptable values are: {', '.join(cls._RANDOM_OPTS)}.")
@classmethod
def custom_range(cls, text) -> Range:
@@ -661,11 +769,11 @@ def custom_range(cls, text) -> Range:
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"):
- return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
+ return cls(cls.triangular(random_range[0], random_range[1], 0.0))
elif text.startswith("random-range-middle"):
return cls(cls.triangular(random_range[0], random_range[1]))
elif text.startswith("random-range-high"):
- return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
+ return cls(cls.triangular(random_range[0], random_range[1], 1.0))
else:
return cls(random.randint(random_range[0], random_range[1]))
@@ -683,15 +791,37 @@ def __str__(self) -> str:
return str(self.value)
@staticmethod
- def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
- return int(round(random.triangular(lower, end, tri), 0))
+ def triangular(lower: int, end: int, tri: float = 0.5) -> int:
+ """
+ Integer triangular distribution for `lower` inclusive to `end` inclusive.
+ Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
+ """
+ # Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
+ # random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
+ # when a != b, so ensure the result is never more than `end`.
+ return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
-class SpecialRange(Range):
- special_range_cutoff = 0
+
+class NamedRange(Range):
special_range_names: typing.Dict[str, int] = {}
"""Special Range names have to be all lowercase as matching is done with text.lower()"""
+ def __init__(self, value: int) -> None:
+ if value < self.range_start and value not in self.special_range_names.values():
+ raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__} " +
+ f"and is also not one of the supported named special values: {self.special_range_names}")
+ elif value > self.range_end and value not in self.special_range_names.values():
+ raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
+ f"and is also not one of the supported named special values: {self.special_range_names}")
+
+ # See docstring
+ for key in self.special_range_names:
+ if key != key.lower():
+ raise Exception(f"{self.__class__.__name__} has an invalid special_range_names key: {key}. "
+ f"NamedRange keys must use only lowercase letters, and ideally should be snake_case.")
+ self.value = value
+
@classmethod
def from_text(cls, text: str) -> Range:
text = text.lower()
@@ -699,27 +829,10 @@ def from_text(cls, text: str) -> Range:
return cls(cls.special_range_names[text])
return super().from_text(text)
- @classmethod
- def weighted_range(cls, text) -> Range:
- if text == "random-low":
- return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.special_range_cutoff))
- elif text == "random-high":
- return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.range_end))
- elif text == "random-middle":
- return cls(cls.triangular(cls.special_range_cutoff, cls.range_end))
- elif text.startswith("random-range-"):
- return cls.custom_range(text)
- elif text == "random":
- return cls(random.randint(cls.special_range_cutoff, cls.range_end))
- else:
- raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
- f"Acceptable values are: random, random-high, random-middle, random-low, "
- f"random-range-low--, random-range-middle--, "
- f"random-range-high--, or random-range--.")
-
class FreezeValidKeys(AssembleOptions):
def __new__(mcs, name, bases, attrs):
+ assert not "_valid_keys" in attrs, "'_valid_keys' gets set by FreezeValidKeys, define 'valid_keys' instead."
if "valid_keys" in attrs:
attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)
@@ -734,17 +847,22 @@ class VerifyKeys(metaclass=FreezeValidKeys):
verify_location_name: bool = False
value: typing.Any
- @classmethod
- 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)
- extra = dataset - cls._valid_keys
+ def verify_keys(self) -> None:
+ if self.valid_keys:
+ data = set(self.value)
+ dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
+ extra = dataset - self._valid_keys
if extra:
- raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
- f"Allowed keys: {cls._valid_keys}.")
+ raise OptionError(
+ f"Found unexpected key {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
+ f"Allowed keys: {self._valid_keys}."
+ )
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
+ try:
+ self.verify_keys()
+ except OptionError as validation_error:
+ raise OptionError(f"Player {player_name} has invalid option keys:\n{validation_error}")
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:
@@ -759,20 +877,23 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
for item_name in self.value:
if item_name not in world.item_names:
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
- raise Exception(f"Item {item_name} from option {self} "
- f"is not a valid item name from {world.game}. "
+ raise Exception(f"Item '{item_name}' from option '{self}' "
+ f"is not a valid item name from '{world.game}'. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
elif self.verify_location_name:
for location_name in self.value:
if location_name not in world.location_names:
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
- raise Exception(f"Location {location_name} from option {self} "
- f"is not a valid location name from {world.game}. "
+ raise Exception(f"Location '{location_name}' from option '{self}' "
+ f"is not a valid location name from '{world.game}'. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
+ def __iter__(self) -> typing.Iterator[typing.Any]:
+ return self.value.__iter__()
+
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
- default: typing.Dict[str, typing.Any] = {}
+ default = {}
supports_weighting = False
def __init__(self, value: typing.Dict[str, typing.Any]):
@@ -781,7 +902,6 @@ def __init__(self, value: typing.Dict[str, typing.Any]):
@classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
if type(data) == dict:
- cls.verify_keys(data)
return cls(data)
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
@@ -790,21 +910,57 @@ def get_option_name(self, value):
return ", ".join(f"{key}: {v}" for key, v in value.items())
def __getitem__(self, item: str) -> typing.Any:
- return self.value.__getitem__(item)
+ return self.value[item]
def __iter__(self) -> typing.Iterator[str]:
- return self.value.__iter__()
+ return iter(self.value)
def __len__(self) -> int:
- return self.value.__len__()
+ return len(self.value)
+
+ # __getitem__ fallback fails for Counters, so we define this explicitly
+ def __contains__(self, item) -> bool:
+ return item in self.value
+
+
+class OptionCounter(OptionDict):
+ min: int | None = None
+ max: int | None = None
+
+ def __init__(self, value: dict[str, int]) -> None:
+ super(OptionCounter, self).__init__(collections.Counter(value))
+
+ def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
+ super(OptionCounter, self).verify(world, player_name, plando_options)
+
+ range_errors = []
+ if self.max is not None:
+ range_errors += [
+ f"\"{key}: {value}\" is higher than maximum allowed value {self.max}."
+ for key, value in self.value.items() if value > self.max
+ ]
-class ItemDict(OptionDict):
+ if self.min is not None:
+ range_errors += [
+ f"\"{key}: {value}\" is lower than minimum allowed value {self.min}."
+ for key, value in self.value.items() if value < self.min
+ ]
+
+ if range_errors:
+ range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors
+ raise OptionError("\n".join(range_errors))
+
+
+class ItemDict(OptionCounter):
verify_item_name = True
- def __init__(self, value: typing.Dict[str, int]):
- if any(item_count < 1 for item_count in value.values()):
- raise Exception("Cannot have non-positive item counts.")
+ min = 0
+
+ def __init__(self, value: dict[str, int]) -> None:
+ # Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
+ value = {item_name: amount for item_name, amount in value.items() if amount != 0}
+
super(ItemDict, self).__init__(value)
@@ -813,11 +969,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
# If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead.
# Not a docstring so it doesn't get grabbed by the options system.
- default: typing.List[typing.Any] = []
+ default = ()
supports_weighting = False
- def __init__(self, value: typing.List[typing.Any]):
- self.value = deepcopy(value)
+ def __init__(self, value: typing.Iterable[typing.Any]):
+ self.value = list(deepcopy(value))
super(OptionList, self).__init__()
@classmethod
@@ -826,8 +982,7 @@ def from_text(cls, text: str):
@classmethod
def from_any(cls, data: typing.Any):
- if type(data) == list:
- cls.verify_keys(data)
+ if is_iterable_except_str(data):
return cls(data)
return cls.from_text(str(data))
@@ -839,7 +994,7 @@ def __contains__(self, item):
class OptionSet(Option[typing.Set[str]], VerifyKeys):
- default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
+ default = frozenset()
supports_weighting = False
def __init__(self, value: typing.Iterable[str]):
@@ -852,8 +1007,7 @@ def from_text(cls, text: str):
@classmethod
def from_any(cls, data: typing.Any):
- if isinstance(data, (list, set, frozenset)):
- cls.verify_keys(data)
+ if is_iterable_except_str(data):
return cls(data)
return cls.from_text(str(data))
@@ -869,26 +1023,301 @@ class ItemSet(OptionSet):
convert_name_groups = True
+class PlandoText(typing.NamedTuple):
+ at: str
+ text: typing.List[str]
+ percentage: int = 100
+
+
+PlandoTextsFromAnyType = typing.Union[
+ typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoText, typing.Any]], typing.Any
+]
+
+
+class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
+ default = ()
+ supports_weighting = False
+ display_name = "Plando Texts"
+
+ visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
+
+ def __init__(self, value: typing.Iterable[PlandoText]) -> None:
+ self.value = list(deepcopy(value))
+ super().__init__()
+
+ def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
+ from BaseClasses import PlandoOptions
+ if self.value and not (PlandoOptions.texts & plando_options):
+ # plando is disabled but plando options were given so overwrite the options
+ self.value = []
+ logging.warning(f"The plando texts module is turned off, "
+ f"so text for {player_name} will be ignored.")
+ else:
+ super().verify(world, player_name, plando_options)
+
+ def verify_keys(self) -> None:
+ if self.valid_keys:
+ data = set(text.at for text in self)
+ dataset = set(word.casefold() for word in data) if self.valid_keys_casefold else set(data)
+ extra = dataset - self._valid_keys
+ if extra:
+ raise OptionError(
+ f"Invalid \"at\" placement {', '.join(extra)} in {getattr(self, 'display_name', self)}. "
+ f"Allowed placements: {self._valid_keys}."
+ )
+
+ @classmethod
+ def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
+ texts: typing.List[PlandoText] = []
+ if isinstance(data, typing.Iterable):
+ for text in data:
+ if isinstance(text, typing.Mapping):
+ if roll_percentage(text.get("percentage", 100)):
+ at = text.get("at", None)
+ if at is not None:
+ if isinstance(at, dict):
+ if at:
+ at = random.choices(list(at.keys()),
+ weights=list(at.values()), k=1)[0]
+ else:
+ raise OptionError("\"at\" must be a valid string or weighted list of strings!")
+ given_text = text.get("text", [])
+ if isinstance(given_text, dict):
+ if not given_text:
+ given_text = []
+ else:
+ given_text = random.choices(list(given_text.keys()),
+ weights=list(given_text.values()), k=1)
+ if isinstance(given_text, str):
+ given_text = [given_text]
+ texts.append(PlandoText(
+ at,
+ given_text,
+ text.get("percentage", 100)
+ ))
+ else:
+ raise OptionError("\"at\" must be a valid string or weighted list of strings!")
+ elif isinstance(text, PlandoText):
+ if roll_percentage(text.percentage):
+ texts.append(text)
+ else:
+ raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
+ return cls(texts)
+ else:
+ raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")
+
+ @classmethod
+ def get_option_name(cls, value: typing.List[PlandoText]) -> str:
+ return str({text.at: " ".join(text.text) for text in value})
+
+ def __iter__(self) -> typing.Iterator[PlandoText]:
+ yield from self.value
+
+ def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
+ return self.value[index]
+
+ def __len__(self) -> int:
+ return len(self.value)
+
+
+class ConnectionsMeta(AssembleOptions):
+ def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict[str, typing.Any]):
+ if name != "PlandoConnections":
+ assert "entrances" in attrs, f"Please define valid entrances for {name}"
+ attrs["entrances"] = frozenset((connection.lower() for connection in attrs["entrances"]))
+ assert "exits" in attrs, f"Please define valid exits for {name}"
+ attrs["exits"] = frozenset((connection.lower() for connection in attrs["exits"]))
+ if "__doc__" not in attrs:
+ attrs["__doc__"] = PlandoConnections.__doc__
+ cls = super().__new__(mcs, name, bases, attrs)
+ return cls
+
+
+class PlandoConnection(typing.NamedTuple):
+ class Direction:
+ entrance = "entrance"
+ exit = "exit"
+ both = "both"
+
+ entrance: str
+ exit: str
+ direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped
+ percentage: int = 100
+
+
+PlandoConFromAnyType = typing.Union[
+ typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoConnection, typing.Any]], typing.Any
+]
+
+
+class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=ConnectionsMeta):
+ """Generic connections plando. Format is:
+ - entrance: "Entrance Name"
+ exit: "Exit Name"
+ direction: "Direction"
+ percentage: 100
+ Direction must be one of 'entrance', 'exit', or 'both', and defaults to 'both' if omitted.
+ Percentage is an integer from 1 to 100, and defaults to 100 when omitted."""
+
+ display_name = "Plando Connections"
+
+ default = ()
+ supports_weighting = False
+
+ entrances: typing.ClassVar[typing.AbstractSet[str]]
+ exits: typing.ClassVar[typing.AbstractSet[str]]
+
+ visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
+
+ duplicate_exits: bool = False
+ """Whether or not exits should be allowed to be duplicate."""
+
+ def __init__(self, value: typing.Iterable[PlandoConnection]):
+ self.value = list(deepcopy(value))
+ super(PlandoConnections, self).__init__()
+
+ @classmethod
+ def validate_entrance_name(cls, entrance: str) -> bool:
+ return entrance.lower() in cls.entrances
+
+ @classmethod
+ def validate_exit_name(cls, exit: str) -> bool:
+ return exit.lower() in cls.exits
+
+ @classmethod
+ def can_connect(cls, entrance: str, exit: str) -> bool:
+ """Checks that a given entrance can connect to a given exit.
+ By default, this will always return true unless overridden."""
+ return True
+
+ @classmethod
+ def validate_plando_connections(cls, connections: typing.Iterable[PlandoConnection]) -> None:
+ used_entrances: typing.List[str] = []
+ used_exits: typing.List[str] = []
+ for connection in connections:
+ entrance = connection.entrance
+ exit = connection.exit
+ direction = connection.direction
+ if direction not in (PlandoConnection.Direction.entrance,
+ PlandoConnection.Direction.exit,
+ PlandoConnection.Direction.both):
+ raise ValueError(f"Unknown direction: {direction}")
+ if entrance in used_entrances:
+ raise ValueError(f"Duplicate Entrance {entrance} not allowed.")
+ if not cls.duplicate_exits and exit in used_exits:
+ raise ValueError(f"Duplicate Exit {exit} not allowed.")
+ used_entrances.append(entrance)
+ used_exits.append(exit)
+ if not cls.validate_entrance_name(entrance):
+ raise ValueError(f"'{entrance.title()}' is not a valid entrance.")
+ if not cls.validate_exit_name(exit):
+ raise ValueError(f"'{exit.title()}' is not a valid exit.")
+ if not cls.can_connect(entrance, exit):
+ raise ValueError(f"Connection between '{entrance.title()}' and '{exit.title()}' is invalid.")
+
+ @classmethod
+ def from_any(cls, data: PlandoConFromAnyType) -> Self:
+ if not isinstance(data, typing.Iterable):
+ raise Exception(f"Cannot create plando connections from non-List value, got {type(data)}.")
+
+ value: typing.List[PlandoConnection] = []
+ for connection in data:
+ if isinstance(connection, typing.Mapping):
+ percentage = connection.get("percentage", 100)
+ if roll_percentage(percentage):
+ entrance = connection.get("entrance", None)
+ if is_iterable_except_str(entrance):
+ entrance = random.choice(sorted(entrance))
+ exit = connection.get("exit", None)
+ if is_iterable_except_str(exit):
+ exit = random.choice(sorted(exit))
+ direction = connection.get("direction", "both")
+
+ if not entrance or not exit:
+ raise Exception("Plando connection must have an entrance and an exit.")
+ value.append(PlandoConnection(
+ entrance,
+ exit,
+ direction,
+ percentage
+ ))
+ elif isinstance(connection, PlandoConnection):
+ if roll_percentage(connection.percentage):
+ value.append(connection)
+ else:
+ raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
+ cls.validate_plando_connections(value)
+ return cls(value)
+
+ def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
+ from BaseClasses import PlandoOptions
+ if self.value and not (PlandoOptions.connections & plando_options):
+ # plando is disabled but plando options were given so overwrite the options
+ self.value = []
+ logging.warning(f"The plando connections module is turned off, "
+ f"so connections for {player_name} will be ignored.")
+
+ @classmethod
+ def get_option_name(cls, value: typing.List[PlandoConnection]) -> str:
+ return ", ".join(["%s %s %s" % (connection.entrance,
+ "<=>" if connection.direction == PlandoConnection.Direction.both else
+ "<=" if connection.direction == PlandoConnection.Direction.exit else
+ "=>",
+ connection.exit) for connection in value])
+
+ def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
+ return self.value[index]
+
+ def __iter__(self) -> typing.Iterator[PlandoConnection]:
+ yield from self.value
+
+ def __len__(self) -> int:
+ return len(self.value)
+
+
class Accessibility(Choice):
- """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."""
+ """
+ Set rules for reachability of your items/locations.
+
+ **Full:** ensure everything can be reached and acquired.
+
+ **Minimal:** ensure what is needed to reach your goal can be acquired.
+ """
display_name = "Accessibility"
- option_locations = 0
- option_items = 1
+ rich_text_doc = True
+ option_full = 0
option_minimal = 2
alias_none = 2
+ alias_locations = 0
+ alias_items = 0
+ default = 0
+
+
+class ItemsAccessibility(Accessibility):
+ """
+ Set rules for reachability of your items/locations.
+
+ **Full:** ensure everything can be reached and acquired.
+
+ **Minimal:** ensure what is needed to reach your goal can be acquired.
+
+ **Items:** ensure all logically relevant items can be acquired. Some items, such as keys, may be self-locking, and
+ some locations may be inaccessible.
+ """
+ option_items = 1
default = 1
-class ProgressionBalancing(SpecialRange):
+class ProgressionBalancing(NamedRange):
"""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."""
+
+ A lower setting means more getting stuck. A higher setting means less getting stuck.
+ """
default = 50
range_start = 0
range_end = 99
display_name = "Progression Balancing"
+ rich_text_doc = True
special_range_names = {
"disabled": 0,
"normal": 50,
@@ -896,38 +1325,107 @@ class ProgressionBalancing(SpecialRange):
}
-common_options = {
- "progression_balancing": ProgressionBalancing,
- "accessibility": Accessibility
-}
+class OptionsMetaProperty(type):
+ def __new__(mcs,
+ name: str,
+ bases: typing.Tuple[type, ...],
+ attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty":
+ for attr_type in attrs.values():
+ assert not isinstance(attr_type, AssembleOptions), \
+ f"Options for {name} should be type hinted on the class, not assigned"
+ return super().__new__(mcs, name, bases, attrs)
+
+ @property
+ @functools.lru_cache(maxsize=None)
+ def type_hints(cls) -> typing.Dict[str, typing.Type[Option[typing.Any]]]:
+ """Returns type hints of the class as a dictionary."""
+ return typing.get_type_hints(cls)
+
+
+@dataclass
+class CommonOptions(metaclass=OptionsMetaProperty):
+ progression_balancing: ProgressionBalancing
+ accessibility: Accessibility
+
+ def as_dict(
+ self,
+ *option_names: str,
+ casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
+ toggles_as_bools: bool = False,
+ ) -> dict[str, typing.Any]:
+ """
+ Returns a dictionary of [str, Option.value]
+
+ :param option_names: Names of the options to get the values of.
+ :param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
+ :param toggles_as_bools: Whether toggle options should be returned as bools instead of ints.
+
+ :return: A dictionary of each option name to the value of its Option. If the option is an OptionSet, the value
+ will be returned as a sorted list.
+ """
+ assert option_names, "options.as_dict() was used without any option names."
+ assert len(option_names) < len(self.__class__.type_hints), "Specify only options you need."
+ option_results = {}
+ for option_name in option_names:
+ if option_name not in type(self).type_hints:
+ raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
+
+ if casing == "snake":
+ display_name = option_name
+ elif casing == "camel":
+ split_name = [name.title() for name in option_name.split("_")]
+ split_name[0] = split_name[0].lower()
+ display_name = "".join(split_name)
+ elif casing == "pascal":
+ display_name = "".join([name.title() for name in option_name.split("_")])
+ elif casing == "kebab":
+ display_name = option_name.replace("_", "-")
+ else:
+ raise ValueError(f"{casing} is invalid casing for as_dict. "
+ "Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
+ value = getattr(self, option_name).value
+ if isinstance(value, set):
+ value = sorted(value)
+ elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
+ value = bool(value)
+ option_results[display_name] = value
+ return option_results
class LocalItems(ItemSet):
"""Forces these items to be in their native world."""
display_name = "Local Items"
+ rich_text_doc = True
class NonLocalItems(ItemSet):
"""Forces these items to be outside their native world."""
- display_name = "Not Local Items"
+ display_name = "Non-local Items"
+ rich_text_doc = True
class StartInventory(ItemDict):
- """Start with these items."""
+ """Start with the specified amount of these items. Example: "Bomb: 1" """
verify_item_name = True
display_name = "Start Inventory"
+ rich_text_doc = True
+ max = 10000
class StartInventoryPool(StartInventory):
- """Start with these items and don't place them in the world.
- The game decides what the replacement items will be."""
+ """Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1"
+
+ The game decides what the replacement items will be.
+ """
verify_item_name = True
display_name = "Start Inventory from Pool"
+ rich_text_doc = True
class StartHints(ItemSet):
- """Start with these item's locations prefilled into the !hint command."""
+ """Start with these item's locations prefilled into the ``!hint`` command."""
display_name = "Start Hints"
+ rich_text_doc = True
class LocationSet(OptionSet):
@@ -936,28 +1434,34 @@ class LocationSet(OptionSet):
class StartLocationHints(LocationSet):
- """Start with these locations and their item prefilled into the !hint command"""
+ """Start with these locations and their item prefilled into the ``!hint`` command."""
display_name = "Start Location Hints"
+ rich_text_doc = True
class ExcludeLocations(LocationSet):
- """Prevent these locations from having an important item"""
+ """Prevent these locations from having an important item."""
display_name = "Excluded Locations"
+ rich_text_doc = True
class PriorityLocations(LocationSet):
- """Prevent these locations from having an unimportant item"""
+ """Prevent these locations from having an unimportant item."""
display_name = "Priority Locations"
+ rich_text_doc = True
class DeathLink(Toggle):
- """When you die, everyone dies. Of course the reverse is true too."""
+ """When you die, everyone who enabled death link dies. Of course, the reverse is true too."""
display_name = "Death Link"
+ rich_text_doc = True
class ItemLinks(OptionList):
"""Share part of your item pool with other players."""
display_name = "Item Links"
+ visibility = Visibility.template | Visibility.complex_ui | Visibility.spoiler
+ rich_text_doc = True
default = []
schema = Schema([
{
@@ -968,11 +1472,13 @@ class ItemLinks(OptionList):
Optional("local_items"): [And(str, len)],
Optional("non_local_items"): [And(str, len)],
Optional("link_replacement"): Or(None, bool),
+ Optional("skip_if_solo"): Or(None, bool),
}
])
@staticmethod
- def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set:
+ def verify_items(items: typing.List[str], item_link: str, pool_name: str, world,
+ allow_item_groups: bool = True) -> typing.Set:
pool = set()
for item_name in items:
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
@@ -980,8 +1486,8 @@ def verify_items(items: typing.List[str], item_link: str, pool_name: str, world,
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1)
picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else ""
- raise Exception(f"Item {item_name} from item link {item_link} "
- f"is not a valid item from {world.game} for {pool_name}. "
+ raise Exception(f"Item '{item_name}' from item link '{item_link}' "
+ f"is not a valid item from '{world.game}' for '{pool_name}'. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
if allow_item_groups:
pool |= world.item_name_groups.get(item_name, {item_name})
@@ -994,8 +1500,10 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
super(ItemLinks, self).verify(world, player_name, plando_options)
existing_links = set()
for link in self.value:
+ link["name"] = link["name"].strip()[:16].strip()
if link["name"] in existing_links:
- raise Exception(f"You cannot have more than one link named {link['name']}.")
+ raise Exception(f"Item link names are limited to their first 16 characters and must be unique. "
+ f"You have more than one link named '{link['name']}'.")
existing_links.add(link["name"])
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
@@ -1018,23 +1526,214 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
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)
+ link["item_pool"] = list(pool)
-per_game_common_options = {
- **common_options, # can be overwritten per-game
- "local_items": LocalItems,
- "non_local_items": NonLocalItems,
- "start_inventory": StartInventory,
- "start_hints": StartHints,
- "start_location_hints": StartLocationHints,
- "exclude_locations": ExcludeLocations,
- "priority_locations": PriorityLocations,
- "item_links": ItemLinks
-}
+@dataclass(frozen=True)
+class PlandoItem:
+ items: list[str] | dict[str, typing.Any]
+ locations: list[str]
+ world: int | str | bool | None | typing.Iterable[str] | set[int] = False
+ from_pool: bool = True
+ force: bool | typing.Literal["silent"] = "silent"
+ count: int | bool | dict[str, int] = False
+ percentage: int = 100
-def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
+class PlandoItems(Option[typing.List[PlandoItem]]):
+ """Generic items plando."""
+ default = ()
+ supports_weighting = False
+ display_name = "Plando Items"
+ visibility = Visibility.template | Visibility.spoiler
+
+ def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
+ self.value = list(deepcopy(value))
+ super().__init__()
+
+ @classmethod
+ def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
+ if not isinstance(data, typing.Iterable):
+ raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
+
+ value: typing.List[PlandoItem] = []
+ for item in data:
+ if isinstance(item, typing.Mapping):
+ percentage = item.get("percentage", 100)
+ if not isinstance(percentage, int):
+ raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
+ if not (0 <= percentage <= 100):
+ raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
+ if roll_percentage(percentage):
+ count = item.get("count", False)
+ items = item.get("items", [])
+ if not items:
+ items = item.get("item", None) # explicitly throw an error here if not present
+ if not items:
+ raise OptionError("You must specify at least one item to place items with plando.")
+ count = 1
+ if isinstance(items, str):
+ items = [items]
+ elif not isinstance(items, (dict, list)):
+ raise OptionError(f"Plando 'items' has to be string, list, or "
+ f"dictionary, not {type(items)}")
+ locations = item.get("locations", [])
+ if not locations:
+ locations = item.get("location", [])
+ if locations:
+ count = 1
+ else:
+ locations = ["Everywhere"]
+ if isinstance(locations, str):
+ locations = [locations]
+ if not isinstance(locations, list):
+ raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
+ world = item.get("world", False)
+ from_pool = item.get("from_pool", True)
+ force = item.get("force", "silent")
+ if not isinstance(from_pool, bool):
+ raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
+ if not (isinstance(force, bool) or force == "silent"):
+ raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
+ value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
+ elif isinstance(item, PlandoItem):
+ if roll_percentage(item.percentage):
+ value.append(item)
+ else:
+ raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
+ return cls(value)
+
+ def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
+ if not self.value:
+ return
+ from BaseClasses import PlandoOptions
+ if not (PlandoOptions.items & plando_options):
+ # plando is disabled but plando options were given so overwrite the options
+ self.value = []
+ logging.warning(f"The plando items module is turned off, "
+ f"so items for {player_name} will be ignored.")
+ else:
+ # filter down item groups
+ for plando in self.value:
+ # confirm a valid count
+ if isinstance(plando.count, dict):
+ if "min" in plando.count and "max" in plando.count:
+ if plando.count["min"] > plando.count["max"]:
+ raise OptionError("Plando cannot have count `min` greater than `max`.")
+ items_copy = plando.items.copy()
+ if isinstance(plando.items, dict):
+ for item in items_copy:
+ if item in world.item_name_groups:
+ value = plando.items.pop(item)
+ group = world.item_name_groups[item]
+ filtered_items = sorted(group.difference(list(plando.items.keys())))
+ if not filtered_items:
+ raise OptionError(f"Plando `items` contains the group \"{item}\" "
+ f"and every item in it. This is not allowed.")
+ if value is True:
+ for key in filtered_items:
+ plando.items[key] = True
+ else:
+ for key in random.choices(filtered_items, k=value):
+ plando.items[key] = plando.items.get(key, 0) + 1
+ else:
+ assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
+ for item in items_copy:
+ if item in world.item_name_groups:
+ plando.items.remove(item)
+ plando.items.extend(sorted(world.item_name_groups[item]))
+
+ @classmethod
+ def get_option_name(cls, value: list[PlandoItem]) -> str:
+ return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
+
+ def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
+ return self.value.__getitem__(index)
+
+ def __iter__(self) -> typing.Iterator[PlandoItem]:
+ yield from self.value
+
+ def __len__(self) -> int:
+ return len(self.value)
+
+
+class Removed(FreeText):
+ """This Option has been Removed."""
+ rich_text_doc = True
+ default = ""
+ visibility = Visibility.none
+
+ def __init__(self, value: str):
+ if value:
+ raise Exception("Option removed, please update your options file.")
+ super().__init__(value)
+
+
+@dataclass
+class PerGameCommonOptions(CommonOptions):
+ local_items: LocalItems
+ non_local_items: NonLocalItems
+ start_inventory: StartInventory
+ start_hints: StartHints
+ start_location_hints: StartLocationHints
+ exclude_locations: ExcludeLocations
+ priority_locations: PriorityLocations
+ item_links: ItemLinks
+ plando_items: PlandoItems
+
+
+@dataclass
+class DeathLinkMixin:
+ death_link: DeathLink
+
+
+class OptionGroup(typing.NamedTuple):
+ """Define a grouping of options."""
+ name: str
+ """Name of the group to categorize these options in for display on the WebHost and in generated YAMLS."""
+ options: typing.List[typing.Type[Option[typing.Any]]]
+ """Options to be in the defined group."""
+ start_collapsed: bool = False
+ """Whether the group will start collapsed on the WebHost options pages."""
+
+
+item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
+ StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks, PlandoItems]
+"""
+Options that are always populated in "Item & Location Options" Option Group. Cannot be moved to another group.
+If desired, a custom "Item & Location Options" Option Group can be defined, but only for adding additional options to
+it.
+"""
+
+
+def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[
+ str, typing.Dict[str, typing.Type[Option[typing.Any]]]]:
+ """Generates and returns a dictionary for the option groups of a specified world."""
+ option_to_name = {option: option_name for option_name, option in world.options_dataclass.type_hints.items()}
+
+ ordered_groups = {group.name: group.options for group in world.web.option_groups}
+
+ # add a default option group for uncategorized options to get thrown into
+ if "Game Options" not in ordered_groups:
+ grouped_options = set(option for group in ordered_groups.values() for option in group)
+ ungrouped_options = [option for option in option_to_name if option not in grouped_options]
+ # only add the game options group if we have ungrouped options
+ if ungrouped_options:
+ ordered_groups = {**{"Game Options": ungrouped_options}, **ordered_groups}
+
+ return {
+ group: {
+ option_to_name[option]: option
+ for option in group_options
+ if (visibility_level in option.visibility and option in option_to_name)
+ }
+ for group, group_options in ordered_groups.items()
+ }
+
+
+def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
import os
+ from inspect import cleandoc
import yaml
from jinja2 import Template
@@ -1052,13 +1751,18 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
os.unlink(full_path)
- def dictify_range(option: typing.Union[Range, SpecialRange]):
+ def dictify_range(option: Range):
data = {option.default: 50}
- for sub_option in ["random", "random-low", "random-high"]:
+ for sub_option in ["random", "random-low", "random-high",
+ f"random-range-{option.range_start}-{option.range_end}"]:
if sub_option != option.default:
data[sub_option] = 0
-
- notes = {}
+ notes = {
+ "random-low": "random value weighted towards lower values",
+ "random-high": "random value weighted towards higher values",
+ f"random-range-{option.range_start}-{option.range_end}": f"random value between "
+ f"{option.range_start} and {option.range_end}"
+ }
for name, number in getattr(option, "special_range_names", {}).items():
notes[name] = f"equivalent to {number}"
if number in data:
@@ -1069,50 +1773,67 @@ def dictify_range(option: typing.Union[Range, SpecialRange]):
return data, notes
+ def yaml_dump_scalar(scalar) -> str:
+ # yaml dump may add end of document marker and newlines.
+ return yaml.dump(scalar).replace("...\n", "").strip()
+
+ with open(local_path("data", "options.yaml")) as f:
+ file_data = f.read()
+ template = Template(file_data)
+
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
- all_options: typing.Dict[str, AssembleOptions] = {
- **per_game_common_options,
- **world.option_definitions
- }
-
- with open(local_path("data", "options.yaml")) as f:
- file_data = f.read()
- res = Template(file_data).render(
- options=all_options,
- __version__=__version__, game=game_name, yaml_dump=yaml.dump,
+ option_groups = get_option_groups(world)
+
+ res = template.render(
+ option_groups=option_groups,
+ __version__=__version__,
+ game=game_name,
+ world_version=world.world_version.as_simple_string(),
+ yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
+ cleandoc=cleandoc,
)
- del file_data
-
- with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
+ with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res)
-if __name__ == "__main__":
-
- from worlds.alttp.Options import Logic
- import argparse
-
- map_shuffle = Toggle
- compass_shuffle = Toggle
- key_shuffle = Toggle
- big_key_shuffle = Toggle
- hints = Toggle
- test = argparse.Namespace()
- test.logic = Logic.from_text("no_logic")
- test.map_shuffle = map_shuffle.from_text("ON")
- test.hints = hints.from_text('OFF')
- try:
- test.logic = Logic.from_text("overworld_glitches_typo")
- except KeyError as e:
- print(e)
- try:
- test.logic_owg = Logic.from_text("owg")
- except KeyError as e:
- print(e)
- if test.map_shuffle:
- print("map_shuffle is on")
- print(f"Hints are {bool(test.hints)}")
- print(test)
+def dump_player_options(multiworld: MultiWorld) -> None:
+ from csv import DictWriter
+
+ game_players = defaultdict(list)
+ for player, game in multiworld.game.items():
+ game_players[game].append(player)
+ game_players = dict(sorted(game_players.items()))
+
+ output = []
+ per_game_option_names = [
+ getattr(option, "display_name", option_key)
+ for option_key, option in PerGameCommonOptions.type_hints.items()
+ ]
+ all_option_names = per_game_option_names.copy()
+ for game, players in game_players.items():
+ game_option_names = per_game_option_names.copy()
+ for player in players:
+ world = multiworld.worlds[player]
+ player_output = {
+ "Game": multiworld.game[player],
+ "Name": multiworld.get_player_name(player),
+ "ID": player,
+ }
+ output.append(player_output)
+ for option_key, option in world.options_dataclass.type_hints.items():
+ if option.visibility == Visibility.none:
+ continue
+ display_name = getattr(option, "display_name", option_key)
+ player_output[display_name] = getattr(world.options, option_key).current_option_name
+ if display_name not in game_option_names:
+ all_option_names.append(display_name)
+ game_option_names.append(display_name)
+
+ with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
+ fields = ["ID", "Game", "Name", *all_option_names]
+ writer = DictWriter(file, fields)
+ writer.writeheader()
+ writer.writerows(output)
diff --git a/OptionsCreator.py b/OptionsCreator.py
new file mode 100644
index 000000000000..6fafda824bae
--- /dev/null
+++ b/OptionsCreator.py
@@ -0,0 +1,674 @@
+if __name__ == "__main__":
+ import ModuleUpdate
+
+ ModuleUpdate.update()
+
+
+from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel,
+ ToggleButton, MarkupDropdown, ResizableTextField)
+from kivy.uix.behaviors.button import ButtonBehavior
+from kivymd.uix.behaviors import RotateBehavior
+from kivymd.uix.anchorlayout import MDAnchorLayout
+from kivymd.uix.expansionpanel import MDExpansionPanel, MDExpansionPanelContent, MDExpansionPanelHeader
+from kivymd.uix.list import MDListItem, MDListItemTrailingIcon, MDListItemSupportingText
+from kivymd.uix.slider import MDSlider
+from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
+from kivymd.uix.menu import MDDropdownMenu
+from kivymd.uix.button import MDButton, MDButtonText, MDIconButton
+from kivymd.uix.dialog import MDDialog
+from kivy.core.text.markup import MarkupLabel
+from kivy.utils import escape_markup
+from kivy.lang.builder import Builder
+from kivy.properties import ObjectProperty
+from textwrap import dedent
+from copy import deepcopy
+import Utils
+import typing
+import webbrowser
+import re
+from urllib.parse import urlparse
+from worlds.AutoWorld import AutoWorldRegister, World
+from Options import (Option, Toggle, TextChoice, Choice, FreeText, NamedRange, Range, OptionSet, OptionList, Removed,
+ OptionCounter, Visibility)
+
+
+def validate_url(x):
+ try:
+ result = urlparse(x)
+ return all([result.scheme, result.netloc])
+ except AttributeError:
+ return False
+
+
+def filter_tooltip(tooltip):
+ if tooltip is None:
+ tooltip = "No tooltip available."
+ tooltip = dedent(tooltip).strip().replace("\n", " ").replace("&", "&") \
+ .replace("[", "&bl;").replace("]", "&br;")
+ tooltip = re.sub(r"\*\*(.+?)\*\*", r"[b]\g<1>[/b]", tooltip)
+ tooltip = re.sub(r"\*(.+?)\*", r"[i]\g<1>[/i]", tooltip)
+ return escape_markup(tooltip)
+
+
+def option_can_be_randomized(option: typing.Type[Option]):
+ # most options can be randomized, so we should just check for those that cannot
+ if not option.supports_weighting:
+ return False
+ elif issubclass(option, FreeText) and not issubclass(option, TextChoice):
+ return False
+ return True
+
+
+def check_random(value: typing.Any):
+ if not isinstance(value, str):
+ return value # cannot be random if evaluated
+ if value.startswith("random-"):
+ return "random"
+ return value
+
+
+class TrailingPressedIconButton(ButtonBehavior, RotateBehavior, MDListItemTrailingIcon):
+ pass
+
+
+class WorldButton(ToggleButton):
+ world_cls: typing.Type[World]
+
+
+class VisualRange(MDBoxLayout):
+ option: typing.Type[Range]
+ name: str
+ tag: MDLabel = ObjectProperty(None)
+ slider: MDSlider = ObjectProperty(None)
+
+ def __init__(self, *args, option: typing.Type[Range], name: str, **kwargs):
+ self.option = option
+ self.name = name
+ super().__init__(*args, **kwargs)
+
+ def update_points(*update_args):
+ pass
+
+ self.slider._update_points = update_points
+
+
+class VisualChoice(MDButton):
+ option: typing.Type[Choice]
+ name: str
+ text: MDButtonText = ObjectProperty(None)
+
+ def __init__(self, *args, option: typing.Type[Choice], name: str, **kwargs):
+ self.option = option
+ self.name = name
+ super().__init__(*args, **kwargs)
+
+
+class VisualNamedRange(MDBoxLayout):
+ option: typing.Type[NamedRange]
+ name: str
+ range: VisualRange = ObjectProperty(None)
+ choice: MDButton = ObjectProperty(None)
+
+ def __init__(self, *args, option: typing.Type[NamedRange], name: str, range_widget: VisualRange, **kwargs):
+ self.option = option
+ self.name = name
+ super().__init__(*args, **kwargs)
+ self.range = range_widget
+ self.add_widget(self.range)
+
+
+class VisualFreeText(ResizableTextField):
+ option: typing.Type[FreeText] | typing.Type[TextChoice]
+ name: str
+
+ def __init__(self, *args, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str, **kwargs):
+ self.option = option
+ self.name = name
+ super().__init__(*args, **kwargs)
+
+
+class VisualTextChoice(MDBoxLayout):
+ option: typing.Type[TextChoice]
+ name: str
+ choice: VisualChoice = ObjectProperty(None)
+ text: VisualFreeText = ObjectProperty(None)
+
+ def __init__(self, *args, option: typing.Type[TextChoice], name: str, choice: VisualChoice,
+ text: VisualFreeText, **kwargs):
+ self.option = option
+ self.name = name
+ super(MDBoxLayout, self).__init__(*args, **kwargs)
+ self.choice = choice
+ self.text = text
+ self.add_widget(self.choice)
+ self.add_widget(self.text)
+
+
+class VisualToggle(MDBoxLayout):
+ button: MDIconButton = ObjectProperty(None)
+ option: typing.Type[Toggle]
+ name: str
+
+ def __init__(self, *args, option: typing.Type[Toggle], name: str, **kwargs):
+ self.option = option
+ self.name = name
+ super().__init__(*args, **kwargs)
+
+
+class CounterItemValue(ResizableTextField):
+ pat = re.compile('[^0-9]')
+
+ def insert_text(self, substring, from_undo=False):
+ return super().insert_text(re.sub(self.pat, "", substring), from_undo=from_undo)
+
+
+class VisualListSetCounter(MDDialog):
+ button: MDIconButton = ObjectProperty(None)
+ option: typing.Type[OptionSet] | typing.Type[OptionList] | typing.Type[OptionCounter]
+ scrollbox: ScrollBox = ObjectProperty(None)
+ add: MDIconButton = ObjectProperty(None)
+ save: MDButton = ObjectProperty(None)
+ input: ResizableTextField = ObjectProperty(None)
+ dropdown: MDDropdownMenu
+ valid_keys: typing.Iterable[str]
+
+ def __init__(self, *args, option: typing.Type[OptionSet] | typing.Type[OptionList],
+ name: str, valid_keys: typing.Iterable[str], **kwargs):
+ self.option = option
+ self.name = name
+ self.valid_keys = valid_keys
+ super().__init__(*args, **kwargs)
+ self.dropdown = MarkupDropdown(caller=self.input, border_margin=dp(2),
+ width=self.input.width, position="bottom")
+ self.input.bind(text=self.on_text)
+ self.input.bind(on_text_validate=self.validate_add)
+
+ def validate_add(self, instance):
+ if self.valid_keys:
+ if self.input.text not in self.valid_keys:
+ MDSnackbar(MDSnackbarText(text="Item must be a valid key for this option."), y=dp(24),
+ pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
+ return
+
+ if not issubclass(self.option, OptionList):
+ if any(self.input.text == child.text.text for child in self.scrollbox.layout.children):
+ MDSnackbar(MDSnackbarText(text="This value is already in the set."), y=dp(24),
+ pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
+ return
+
+ self.add_set_item(self.input.text)
+ self.input.set_text(self.input, "")
+
+ def remove_item(self, button: MDIconButton):
+ list_item = button.parent
+ self.scrollbox.layout.remove_widget(list_item)
+
+ def add_set_item(self, key: str, value: int | None = None):
+ text = MDListItemSupportingText(text=key, id="value")
+ if issubclass(self.option, OptionCounter):
+ value_txt = CounterItemValue(text=str(value) if value else "1")
+ item = MDListItem(text,
+ value_txt,
+ MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
+ item.value = value_txt
+ else:
+ item = MDListItem(text, MDIconButton(icon="minus", on_release=self.remove_item), focus_behavior=False)
+ item.text = text
+ self.scrollbox.layout.add_widget(item)
+
+ def on_text(self, instance, value):
+ if not self.valid_keys:
+ return
+ if len(value) >= 3:
+ self.dropdown.items.clear()
+
+ def on_press(txt):
+ split_text = MarkupLabel(text=txt, markup=True).markup
+ self.input.set_text(self.input, "".join(text_frag for text_frag in split_text
+ if not text_frag.startswith("[")))
+ self.input.focus = True
+ self.dropdown.dismiss()
+
+ lowered = value.lower()
+ for item_name in self.valid_keys:
+ try:
+ index = item_name.lower().index(lowered)
+ except ValueError:
+ pass # substring not found
+ else:
+ text = escape_markup(item_name)
+ text = text[:index] + "[b]" + text[index:index + len(value)] + "[/b]" + text[index + len(value):]
+ self.dropdown.items.append({
+ "text": text,
+ "on_release": lambda txt=text: on_press(txt),
+ "markup": True
+ })
+ if not self.dropdown.parent:
+ self.dropdown.open()
+ else:
+ self.dropdown.dismiss()
+
+
+class OptionsCreator(ThemedApp):
+ base_title: str = "Archipelago Options Creator"
+ container: ContainerLayout
+ main_layout: MainLayout
+ scrollbox: ScrollBox
+ main_panel: MainLayout
+ player_options: MainLayout
+ option_layout: MainLayout
+ name_input: ResizableTextField
+ game_label: MDLabel
+ current_game: str
+ options: typing.Dict[str, typing.Any]
+
+ def __init__(self):
+ self.title = self.base_title + " " + Utils.__version__
+ self.icon = r"data/icon.png"
+ self.current_game = ""
+ self.options = {}
+ super().__init__()
+
+ def export_options(self, button: Widget):
+ if 0 < len(self.name_input.text) < 17 and self.current_game:
+ file_name = Utils.save_filename("Export Options File As...", [("YAML", ["*.yaml"])],
+ Utils.get_file_safe_name(f"{self.name_input.text}.yaml"))
+ options = {
+ "name": self.name_input.text,
+ "description": f"YAML generated by Archipelago {Utils.__version__}.",
+ "game": self.current_game,
+ self.current_game: {k: check_random(v) for k, v in self.options.items()}
+ }
+ try:
+ with open(file_name, 'w') as f:
+ f.write(Utils.dump(options, sort_keys=False))
+ f.close()
+ MDSnackbar(MDSnackbarText(text="File saved successfully."), y=dp(24), pos_hint={"center_x": 0.5},
+ size_hint_x=0.5).open()
+ except FileNotFoundError:
+ MDSnackbar(MDSnackbarText(text="Saving cancelled."), y=dp(24), pos_hint={"center_x": 0.5},
+ size_hint_x=0.5).open()
+ elif not self.name_input.text:
+ MDSnackbar(MDSnackbarText(text="Name must not be empty."), y=dp(24), pos_hint={"center_x": 0.5},
+ size_hint_x=0.5).open()
+ elif not self.current_game:
+ MDSnackbar(MDSnackbarText(text="You must select a game to play."), y=dp(24), pos_hint={"center_x": 0.5},
+ size_hint_x=0.5).open()
+ else:
+ MDSnackbar(MDSnackbarText(text="Name cannot be longer than 16 characters."), y=dp(24),
+ pos_hint={"center_x": 0.5}, size_hint_x=0.5).open()
+
+ def create_range(self, option: typing.Type[Range], name: str):
+ def update_text(range_box: VisualRange):
+ self.options[name] = int(range_box.slider.value)
+ range_box.tag.text = str(int(range_box.slider.value))
+ return
+
+ box = VisualRange(option=option, name=name)
+ box.slider.bind(on_touch_move=lambda _, _1: update_text(box))
+ self.options[name] = option.default
+ return box
+
+ def create_named_range(self, option: typing.Type[NamedRange], name: str):
+ def set_to_custom(range_box: VisualNamedRange):
+ if (not self.options[name] == range_box.range.slider.value) \
+ and (not self.options[name] in option.special_range_names or
+ range_box.range.slider.value != option.special_range_names[self.options[name]]):
+ # we should validate the touch here,
+ # but this is much cheaper
+ self.options[name] = int(range_box.range.slider.value)
+ range_box.range.tag.text = str(int(range_box.range.slider.value))
+ set_button_text(range_box.choice, "Custom")
+
+ def set_button_text(button: MDButton, text: str):
+ button.text.text = text
+
+ def set_value(text: str, range_box: VisualNamedRange):
+ range_box.range.slider.value = min(max(option.special_range_names[text.lower()], option.range_start),
+ option.range_end)
+ range_box.range.tag.text = str(int(range_box.range.slider.value))
+ set_button_text(range_box.choice, text)
+ self.options[name] = text.lower()
+ range_box.range.slider.dropdown.dismiss()
+
+ def open_dropdown(button):
+ # for some reason this fixes an issue causing some to not open
+ box.range.slider.dropdown.open()
+
+ box = VisualNamedRange(option=option, name=name, range_widget=self.create_range(option, name))
+ if option.default in option.special_range_names:
+ # value can get mismatched in this case
+ box.range.slider.value = min(max(option.special_range_names[option.default], option.range_start),
+ option.range_end)
+ box.range.tag.text = str(int(box.range.slider.value))
+ box.range.slider.bind(on_touch_move=lambda _, _2: set_to_custom(box))
+ items = [
+ {
+ "text": choice.title(),
+ "on_release": lambda text=choice.title(): set_value(text, box)
+ }
+ for choice in option.special_range_names
+ ]
+ box.range.slider.dropdown = MDDropdownMenu(caller=box.choice, items=items)
+ box.choice.bind(on_release=open_dropdown)
+ self.options[name] = option.default
+ return box
+
+ def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str):
+ text = VisualFreeText(option=option, name=name)
+
+ def set_value(instance):
+ self.options[name] = instance.text
+
+ text.bind(on_text_validate=set_value)
+ return text
+
+ def create_choice(self, option: typing.Type[Choice], name: str):
+ def set_button_text(button: VisualChoice, text: str):
+ button.text.text = text
+
+ def set_value(text, value):
+ set_button_text(main_button, text)
+ self.options[name] = value
+ dropdown.dismiss()
+
+ def open_dropdown(button):
+ # for some reason this fixes an issue causing some to not open
+ dropdown.open()
+
+ default_string = isinstance(option.default, str)
+ main_button = VisualChoice(option=option, name=name)
+ main_button.bind(on_release=open_dropdown)
+
+ items = [
+ {
+ "text": option.get_option_name(choice),
+ "on_release": lambda val=choice: set_value(option.get_option_name(val), option.name_lookup[val])
+ }
+ for choice in option.name_lookup
+ ]
+ dropdown = MDDropdownMenu(caller=main_button, items=items)
+ self.options[name] = option.name_lookup[option.default] if not default_string else option.default
+ return main_button
+
+ def create_text_choice(self, option: typing.Type[TextChoice], name: str):
+ def set_button_text(button: MDButton, text: str):
+ for child in button.children:
+ if isinstance(child, MDButtonText):
+ child.text = text
+
+ box = VisualTextChoice(option=option, name=name, choice=self.create_choice(option, name),
+ text=self.create_free_text(option, name))
+
+ def set_value(instance):
+ set_button_text(box.choice, "Custom")
+ self.options[name] = instance.text
+
+ box.text.bind(on_text_validate=set_value)
+ return box
+
+ def create_toggle(self, option: typing.Type[Toggle], name: str) -> Widget:
+ def set_value(instance: MDIconButton):
+ if instance.icon == "checkbox-outline":
+ instance.icon = "checkbox-blank-outline"
+ else:
+ instance.icon = "checkbox-outline"
+ self.options[name] = bool(not self.options[name])
+
+ self.options[name] = bool(option.default)
+ checkbox = VisualToggle(option=option, name=name)
+ checkbox.button.bind(on_release=set_value)
+
+ return checkbox
+
+ def create_popup(self, option: typing.Type[OptionList] | typing.Type[OptionSet] | typing.Type[OptionCounter],
+ name: str, world: typing.Type[World]):
+
+ valid_keys = sorted(option.valid_keys)
+ if option.verify_item_name:
+ valid_keys += list(world.item_name_to_id.keys())
+ if option.verify_location_name:
+ valid_keys += list(world.location_name_to_id.keys())
+
+ if not issubclass(option, OptionCounter):
+ def apply_changes(button):
+ self.options[name].clear()
+ for list_item in dialog.scrollbox.layout.children:
+ self.options[name].append(getattr(list_item.text, "text"))
+ dialog.dismiss()
+ else:
+ def apply_changes(button):
+ self.options[name].clear()
+ for list_item in dialog.scrollbox.layout.children:
+ self.options[name][getattr(list_item.text, "text")] = int(getattr(list_item.value, "text"))
+ dialog.dismiss()
+
+ dialog = VisualListSetCounter(option=option, name=name, valid_keys=valid_keys)
+ dialog.ids.container.spacing = dp(30)
+ dialog.scrollbox.layout.theme_bg_color = "Custom"
+ dialog.scrollbox.layout.md_bg_color = self.theme_cls.surfaceContainerLowColor
+ dialog.scrollbox.layout.spacing = dp(5)
+ dialog.scrollbox.layout.padding = [0, dp(5), 0, 0]
+
+ if name not in self.options:
+ # convert from non-mutable to mutable
+ # We use list syntax even for sets, set behavior is enforced through GUI
+ if issubclass(option, OptionCounter):
+ self.options[name] = deepcopy(option.default)
+ else:
+ self.options[name] = sorted(option.default)
+
+ if issubclass(option, OptionCounter):
+ for value in sorted(self.options[name]):
+ dialog.add_set_item(value, self.options[name].get(value, None))
+ else:
+ for value in sorted(self.options[name]):
+ dialog.add_set_item(value)
+
+ dialog.save.bind(on_release=apply_changes)
+ dialog.open()
+
+ def create_option_set_list_counter(self, option: typing.Type[OptionList] | typing.Type[OptionSet] |
+ typing.Type[OptionCounter], name: str, world: typing.Type[World]):
+ main_button = MDButton(MDButtonText(text="Edit"), on_release=lambda x: self.create_popup(option, name, world))
+ return main_button
+
+ def create_option(self, option: typing.Type[Option], name: str, world: typing.Type[World]) -> Widget:
+ option_base = MDBoxLayout(orientation="vertical", size_hint_y=None, padding=[0, 0, dp(5), dp(5)])
+
+ tooltip = filter_tooltip(option.__doc__)
+ option_label = TooltipLabel(text=f"[ref=0|{tooltip}]{getattr(option, 'display_name', name)}")
+ label_box = MDBoxLayout(orientation="horizontal")
+ label_anchor = MDAnchorLayout(anchor_x="right", anchor_y="center")
+ label_anchor.add_widget(option_label)
+ label_box.add_widget(label_anchor)
+
+ option_base.add_widget(label_box)
+ if issubclass(option, NamedRange):
+ option_base.add_widget(self.create_named_range(option, name))
+ elif issubclass(option, Range):
+ option_base.add_widget(self.create_range(option, name))
+ elif issubclass(option, Toggle):
+ option_base.add_widget(self.create_toggle(option, name))
+ elif issubclass(option, TextChoice):
+ option_base.add_widget(self.create_text_choice(option, name))
+ elif issubclass(option, Choice):
+ option_base.add_widget(self.create_choice(option, name))
+ elif issubclass(option, FreeText):
+ option_base.add_widget(self.create_free_text(option, name))
+ elif any(issubclass(option, cls) for cls in (OptionSet, OptionList, OptionCounter)):
+ option_base.add_widget(self.create_option_set_list_counter(option, name, world))
+ else:
+ option_base.add_widget(MDLabel(text="This option isn't supported by the option creator.\n"
+ "Please edit your yaml manually to set this option."))
+
+ if option_can_be_randomized(option):
+ def randomize_option(instance: Widget, value: str):
+ value = value == "down"
+ if value:
+ self.options[name] = "random-" + str(self.options[name])
+ else:
+ self.options[name] = self.options[name].replace("random-", "")
+ if self.options[name].isnumeric() or self.options[name] in ("True", "False"):
+ self.options[name] = eval(self.options[name])
+
+ base_object = instance.parent.parent
+ label_object = instance.parent
+ for child in base_object.children:
+ if child is not label_object:
+ child.disabled = value
+
+ default_random = option.default == "random"
+ random_toggle = ToggleButton(MDButtonText(text="Random?"), size_hint_x=None, width=dp(100),
+ state="down" if default_random else "normal")
+ random_toggle.bind(state=randomize_option)
+ label_box.add_widget(random_toggle)
+ if default_random:
+ randomize_option(random_toggle, "down")
+
+ return option_base
+
+ def create_options_panel(self, world_button: WorldButton):
+ self.option_layout.clear_widgets()
+ self.options.clear()
+ cls: typing.Type[World] = world_button.world_cls
+
+ self.current_game = cls.game
+ if not cls.web.options_page:
+ self.current_game = "None"
+ return
+ elif isinstance(cls.web.options_page, str):
+ self.current_game = "None"
+ if validate_url(cls.web.options_page):
+ webbrowser.open(cls.web.options_page)
+ MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24), pos_hint={"center_x": 0.5},
+ size_hint_x=0.5).open()
+ world_button.state = "normal"
+ else:
+ # attach onto archipelago.gg and see if we pass
+ new_url = "https://archipelago.gg/" + cls.web.options_page
+ if validate_url(new_url):
+ webbrowser.open(new_url)
+ MDSnackbar(MDSnackbarText(text="Launching in default browser..."), y=dp(24),
+ pos_hint={"center_x": 0.5},
+ size_hint_x=0.5).open()
+ else:
+ MDSnackbar(MDSnackbarText(text="Invalid options page, please report to world developer."), y=dp(24),
+ pos_hint={"center_x": 0.5},
+ size_hint_x=0.5).open()
+ world_button.state = "normal"
+ # else just fall through
+ else:
+ expansion_box = ScrollBox()
+ expansion_box.layout.orientation = "vertical"
+ expansion_box.layout.spacing = dp(3)
+ expansion_box.scroll_type = ["bars"]
+ expansion_box.do_scroll_x = False
+ group_names = ["Game Options", *(group.name for group in cls.web.option_groups)]
+ groups = {name: [] for name in group_names}
+ for name, option in cls.options_dataclass.type_hints.items():
+ group = next((group.name for group in cls.web.option_groups if option in group.options), "Game Options")
+ groups[group].append((name, option))
+
+ for group, options in groups.items():
+ options = [(name, option) for name, option in options
+ if name and option.visibility & Visibility.simple_ui]
+ if not options:
+ continue # Game Options can be empty if every other option is in another group
+ # Can also have an option group of options that should not render on simple ui
+ group_item = MDExpansionPanel(size_hint_y=None)
+ group_header = MDExpansionPanelHeader(MDListItem(MDListItemSupportingText(text=group),
+ TrailingPressedIconButton(icon="chevron-right",
+ on_release=lambda x,
+ item=group_item:
+ self.tap_expansion_chevron(
+ item, x)),
+ md_bg_color=self.theme_cls.surfaceContainerLowestColor,
+ theme_bg_color="Custom",
+ on_release=lambda x, item=group_item:
+ self.tap_expansion_chevron(item, x)))
+ group_content = MDExpansionPanelContent(orientation="vertical", theme_bg_color="Custom",
+ md_bg_color=self.theme_cls.surfaceContainerLowestColor,
+ padding=[dp(12), dp(100), dp(12), 0],
+ spacing=dp(3))
+ group_item.add_widget(group_header)
+ group_item.add_widget(group_content)
+ group_box = ScrollBox()
+ group_box.layout.orientation = "vertical"
+ group_box.layout.spacing = dp(3)
+ for name, option in options:
+ group_content.add_widget(self.create_option(option, name, cls))
+ expansion_box.layout.add_widget(group_item)
+ self.option_layout.add_widget(expansion_box)
+ self.game_label.text = f"Game: {self.current_game}"
+
+ @staticmethod
+ def tap_expansion_chevron(panel: MDExpansionPanel, chevron: TrailingPressedIconButton | MDListItem):
+ if isinstance(chevron, MDListItem):
+ chevron = next((child for child in chevron.ids.trailing_container.children
+ if isinstance(child, TrailingPressedIconButton)), None)
+ panel.open() if not panel.is_open else panel.close()
+ if chevron:
+ panel.set_chevron_down(
+ chevron
+ ) if not panel.is_open else panel.set_chevron_up(chevron)
+
+ def build(self):
+ self.set_colors()
+ self.options = {}
+ self.container = Builder.load_file(Utils.local_path("data/optionscreator.kv"))
+ self.root = self.container
+ self.main_layout = self.container.ids.main
+ self.scrollbox = self.container.ids.scrollbox
+
+ def world_button_action(world_btn: WorldButton):
+ if self.current_game != world_btn.world_cls.game:
+ old_button = next((button for button in self.scrollbox.layout.children
+ if button.world_cls.game == self.current_game), None)
+ if old_button:
+ old_button.state = "normal"
+ else:
+ world_btn.state = "down"
+ self.create_options_panel(world_btn)
+
+ for world, cls in sorted(AutoWorldRegister.world_types.items(), key=lambda x: x[0]):
+ if world == "Archipelago":
+ continue
+ world_text = MDButtonText(text=world, size_hint_y=None, width=dp(150),
+ pos_hint={"x": 0.03, "center_y": 0.5})
+ world_text.text_size = (world_text.width, None)
+ world_text.bind(width=lambda *x, text=world_text: text.setter('text_size')(text, (text.width, None)),
+ texture_size=lambda *x, text=world_text: text.setter("height")(text,
+ world_text.texture_size[1]))
+ world_button = WorldButton(world_text, size_hint_x=None, width=dp(150), theme_width="Custom",
+ radius=(dp(5), dp(5), dp(5), dp(5)))
+ world_button.bind(on_release=world_button_action)
+ world_button.world_cls = cls
+ self.scrollbox.layout.add_widget(world_button)
+ self.main_panel = self.container.ids.player_layout
+ self.player_options = self.container.ids.player_options
+ self.game_label = self.container.ids.game
+ self.name_input = self.container.ids.player_name
+ self.option_layout = self.container.ids.options
+
+ def set_height(instance, value):
+ instance.height = value[1]
+
+ self.game_label.bind(texture_size=set_height)
+
+ # Uncomment to re-enable the Kivy console/live editor
+ # Ctrl-E to enable it, make sure numlock/capslock is disabled
+ # from kivy.modules.console import create_console
+ # from kivy.core.window import Window
+ # create_console(Window, self.container)
+
+ return self.container
+
+
+def launch():
+ OptionsCreator().run()
+
+
+if __name__ == "__main__":
+ Utils.init_logging("OptionsCreator")
+ launch()
diff --git a/Patch.py b/Patch.py
index 113d0658c6b7..9b49876bb72d 100644
--- a/Patch.py
+++ b/Patch.py
@@ -8,7 +8,7 @@
import ModuleUpdate
ModuleUpdate.update()
-from worlds.Files import AutoPatchRegister, APDeltaPatch
+from worlds.Files import AutoPatchRegister, APAutoPatchInterface
class RomMeta(TypedDict):
@@ -20,7 +20,7 @@ class RomMeta(TypedDict):
def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
auto_handler = AutoPatchRegister.get_handler(patch_file)
if auto_handler:
- handler: APDeltaPatch = auto_handler(patch_file)
+ handler: APAutoPatchInterface = auto_handler(patch_file)
target = os.path.splitext(patch_file)[0]+handler.result_file_ending
handler.patch(target)
return {"server": handler.server,
diff --git a/PokemonClient.py b/PokemonClient.py
deleted file mode 100644
index 6b43a53b8ff7..000000000000
--- a/PokemonClient.py
+++ /dev/null
@@ -1,382 +0,0 @@
-import asyncio
-import json
-import time
-import os
-import bsdiff4
-import subprocess
-import zipfile
-from asyncio import StreamReader, StreamWriter
-from typing import List
-
-
-import Utils
-from Utils import async_start
-from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
- get_base_parser
-
-from worlds.pokemon_rb.locations import location_data
-from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch
-
-location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}}
-location_bytes_bits = {}
-for location in location_data:
- if location.ram_address is not None:
- if type(location.ram_address) == list:
- location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address
- location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit},
- {'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}]
- else:
- location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address
- location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit}
-
-location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item"
- and location.address is not None}
-
-SYSTEM_MESSAGE_ID = 0
-
-CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
-CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure pkmn_rb.lua is running"
-CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart pkmn_rb.lua"
-CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
-CONNECTION_CONNECTED_STATUS = "Connected"
-CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
-
-DISPLAY_MSGS = True
-
-SCRIPT_VERSION = 3
-
-
-class GBCommandProcessor(ClientCommandProcessor):
- def __init__(self, ctx: CommonContext):
- super().__init__(ctx)
-
- def _cmd_gb(self):
- """Check Gameboy Connection State"""
- if isinstance(self.ctx, GBContext):
- logger.info(f"Gameboy Status: {self.ctx.gb_status}")
-
-
-class GBContext(CommonContext):
- command_processor = GBCommandProcessor
- game = 'Pokemon Red and Blue'
-
- def __init__(self, server_address, password):
- super().__init__(server_address, password)
- self.gb_streams: (StreamReader, StreamWriter) = None
- self.gb_sync_task = None
- self.messages = {}
- self.locations_array = None
- 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
- self.auto_hints = set()
-
- async def server_auth(self, password_requested: bool = False):
- if password_requested and not self.password:
- await super(GBContext, self).server_auth(password_requested)
- if not self.auth:
- self.awaiting_rom = True
- logger.info('Awaiting connection to EmuHawk 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.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':
- msg = args['text']
- if ': !' not in msg:
- self._set_message(msg, SYSTEM_MESSAGE_ID)
- elif cmd == "ReceivedItems":
- msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
- self._set_message(msg, SYSTEM_MESSAGE_ID)
-
- def on_deathlink(self, data: dict):
- self.deathlink_pending = True
- super().on_deathlink(data)
-
- def run_gui(self):
- from kvui import GameManager
-
- class GBManager(GameManager):
- logging_pairs = [
- ("Client", "Archipelago")
- ]
- base_title = "Archipelago PokÊmon Client"
-
- self.ui = GBManager(self)
- self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
-
-
-def get_payload(ctx: GBContext):
- current_time = time.time()
- 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},
- "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:0x140 + 0x20 + 0x0E + 0x01]}
-
- 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():
- if flag_type == "list":
- if (flags["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << location_bytes_bits[loc_id][0]['bit']
- and flags["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << location_bytes_bits[loc_id][1]['bit']):
- locations.append(loc_id)
- elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
- locations.append(loc_id)
-
- hints = []
- if flags["EventFlag"][280] & 16:
- hints.append("Cerulean Bicycle Shop")
- if flags["EventFlag"][280] & 32:
- hints.append("Route 2 Gate - Oak's Aide")
- if flags["EventFlag"][280] & 64:
- hints.append("Route 11 Gate 2F - Oak's Aide")
- if flags["EventFlag"][280] & 128:
- hints.append("Route 15 Gate 2F - Oak's Aide")
- if flags["EventFlag"][281] & 1:
- hints += ["Celadon Prize Corner - Item Prize 1", "Celadon Prize Corner - Item Prize 2",
- "Celadon Prize Corner - Item Prize 3"]
- if (location_name_to_id["Fossil - Choice A"] in ctx.checked_locations and location_name_to_id["Fossil - Choice B"]
- not in ctx.checked_locations):
- hints.append("Fossil - Choice B")
- elif (location_name_to_id["Fossil - Choice B"] in ctx.checked_locations and location_name_to_id["Fossil - Choice A"]
- not in ctx.checked_locations):
- hints.append("Fossil - Choice A")
- hints = [
- location_name_to_id[loc] for loc in hints if location_name_to_id[loc] not in ctx.auto_hints and
- location_name_to_id[loc] in ctx.missing_locations and location_name_to_id[loc] not in ctx.locations_checked
- ]
- if hints:
- await ctx.send_msgs([{"cmd": "LocationScouts", "locations": hints, "create_as_hint": 2}])
- ctx.auto_hints.update(hints)
-
- if flags["EventFlag"][280] & 1 and not ctx.finished_game:
- await ctx.send_msgs([
- {"cmd": "StatusUpdate",
- "status": 30}
- ])
- ctx.finished_game = True
- if locations == ctx.locations_array:
- return
- ctx.locations_array = locations
- if locations is not None:
- await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
-
-
-async def gb_sync_task(ctx: GBContext):
- logger.info("Starting GB connector. Use /gb for status information")
- while not ctx.exit_event.is_set():
- error_status = None
- if ctx.gb_streams:
- (reader, writer) = ctx.gb_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 '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})
- ctx.gui_error('Error', msg)
- error_status = CONNECTION_RESET_STATUS
- ctx.seed_name = ''.join([chr(i) for i in data_decoded['seedName'] if i != 0])
- if not ctx.auth:
- ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
- if ctx.auth == '':
- 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
- writer.close()
- ctx.gb_streams = None
- except ConnectionResetError as e:
- logger.debug("Read failed due to Connection Lost, Reconnecting")
- error_status = CONNECTION_RESET_STATUS
- writer.close()
- ctx.gb_streams = None
- except TimeoutError:
- logger.debug("Connection Timed Out, Reconnecting")
- error_status = CONNECTION_TIMING_OUT_STATUS
- writer.close()
- ctx.gb_streams = None
- except ConnectionResetError:
- logger.debug("Connection Lost, Reconnecting")
- error_status = CONNECTION_RESET_STATUS
- writer.close()
- ctx.gb_streams = None
- if ctx.gb_status == CONNECTION_TENTATIVE_STATUS:
- if not error_status:
- logger.info("Successfully Connected to Gameboy")
- ctx.gb_status = CONNECTION_CONNECTED_STATUS
- else:
- ctx.gb_status = f"Was tentatively connected but error occured: {error_status}"
- elif error_status:
- ctx.gb_status = error_status
- logger.info("Lost connection to Gameboy and attempting to reconnect. Use /gb for status updates")
- else:
- try:
- logger.debug("Attempting to connect to Gameboy")
- ctx.gb_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 17242), timeout=10)
- ctx.gb_status = CONNECTION_TENTATIVE_STATUS
- except TimeoutError:
- logger.debug("Connection Timed Out, Trying Again")
- ctx.gb_status = CONNECTION_TIMING_OUT_STATUS
- continue
- except ConnectionRefusedError:
- logger.debug("Connection Refused, Trying Again")
- ctx.gb_status = CONNECTION_REFUSED_STATUS
- continue
-
-
-async def run_game(romfile):
- auto_start = Utils.get_options()["pokemon_rb_options"].get("rom_start", True)
- if auto_start is True:
- import webbrowser
- webbrowser.open(romfile)
- elif os.path.isfile(auto_start):
- subprocess.Popen([auto_start, romfile],
- stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
-
-
-async def patch_and_run_game(game_version, patch_file, ctx):
- base_name = os.path.splitext(patch_file)[0]
- comp_path = base_name + '.gb'
- 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:
- patch = stream.read()
- patched_rom_data = bsdiff4.patch(base_rom, patch)
-
- with open(comp_path, "wb") as patched_rom_file:
- patched_rom_file.write(patched_rom_data)
-
- async_start(run_game(comp_path))
-
-
-if __name__ == '__main__':
-
- Utils.init_logging("PokemonClient")
-
- options = Utils.get_options()
-
- async def main():
- parser = get_base_parser()
- parser.add_argument('patch_file', default="", type=str, nargs="?",
- help='Path to an APRED or APBLUE patch file')
- args = parser.parse_args()
-
- ctx = GBContext(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.gb_sync_task = asyncio.create_task(gb_sync_task(ctx), name="GB Sync")
-
- if args.patch_file:
- ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
- if ext == "apred":
- logger.info("APRED file supplied, beginning patching process...")
- async_start(patch_and_run_game("red", args.patch_file, ctx))
- elif ext == "apblue":
- logger.info("APBLUE file supplied, beginning patching process...")
- async_start(patch_and_run_game("blue", args.patch_file, ctx))
- else:
- logger.warning(f"Unknown patch file extension {ext}")
-
- await ctx.exit_event.wait()
- ctx.server_address = None
-
- await ctx.shutdown()
-
- if ctx.gb_sync_task:
- await ctx.gb_sync_task
-
-
- import colorama
-
- colorama.init()
-
- asyncio.run(main())
- colorama.deinit()
diff --git a/README.md b/README.md
index 54b659397f1b..608af1313c2f 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,29 @@
# [Archipelago](https://archipelago.gg)  | [Install](https://github.com/ArchipelagoMW/Archipelago/releases)
-Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, presently, Archipelago is also the randomizer itself.
+Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases,
+presently, Archipelago is also the randomizer itself.
Currently, the following games are supported:
+
* The Legend of Zelda: A Link to the Past
* Factorio
-* Minecraft
* Subnautica
-* Slay the Spire
* Risk of Rain 2
* The Legend of Zelda: Ocarina of Time
* Timespinner
* Super Metroid
* Secret of Evermore
* Final Fantasy
-* Rogue Legacy
* VVVVVV
* Raft
* Super Mario 64
* Meritous
* Super Metroid/Link to the Past combo randomizer (SMZ3)
* ChecksFinder
-* ArchipIDLE
* Hollow Knight
* The Witness
* Sonic Adventure 2: Battle
-* Starcraft 2: Wings of Liberty
+* Starcraft 2
* Donkey Kong Country 3
* Dark Souls 3
* Super Mario World
@@ -41,7 +39,6 @@ Currently, the following games are supported:
* The Messenger
* Kingdom Hearts 2
* The Legend of Zelda: Link's Awakening DX
-* Clique
* Adventure
* DLC Quest
* Noita
@@ -51,6 +48,41 @@ Currently, the following games are supported:
* Muse Dash
* DOOM 1993
* Terraria
+* Lingo
+* PokÊmon Emerald
+* DOOM II
+* Shivers
+* Heretic
+* Landstalker: The Treasures of King Nole
+* Final Fantasy Mystic Quest
+* TUNIC
+* Kirby's Dream Land 3
+* Celeste 64
+* Castlevania 64
+* A Short Hike
+* Yoshi's Island
+* Mario & Luigi: Superstar Saga
+* Bomb Rush Cyberfunk
+* Aquaria
+* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
+* A Hat in Time
+* Old School Runescape
+* Kingdom Hearts 1
+* Mega Man 2
+* Yacht Dice
+* Faxanadu
+* Saving Princess
+* Castlevania: Circle of the Moon
+* Inscryption
+* Civilization VI
+* The Legend of Zelda: The Wind Waker
+* Jak and Daxter: The Precursor Legacy
+* Super Mario Land 2: 6 Golden Coins
+* shapez
+* Paint
+* Celeste (Open World)
+* Choo-Choo Charles
+* APQuest
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
@@ -58,36 +90,57 @@ windows binaries.
## History
-Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here. The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are:
+Archipelago is built upon a strong legacy of brilliant hobbyists. We want to honor that legacy by showing it here.
+The repositories which Archipelago is built upon, inspired by, or otherwise owes its gratitude to are:
* [bonta0's MultiWorld](https://github.com/Bonta0/ALttPEntranceRandomizer/tree/multiworld_31)
* [AmazingAmpharos' Entrance Randomizer](https://github.com/AmazingAmpharos/ALttPEntranceRandomizer)
* [VT Web Randomizer](https://github.com/sporchia/alttp_vt_randomizer)
* [Dessyreqt's alttprandomizer](https://github.com/Dessyreqt/alttprandomizer)
-* [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89) and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make the vast majority of Enemizer contributions.
+* [Zarby89's](https://github.com/Ijwu/Enemizer/commits?author=Zarby89)
+ and [sosuke3's](https://github.com/Ijwu/Enemizer/commits?author=sosuke3) contributions to Enemizer, which make up the
+ vast majority of Enemizer contributions.
-We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the path. Just because one person's name may be in a repository title does not mean that only one person made that project happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago but we hope to honor them fairly.
+We recognize that there is a strong community of incredibly smart people that have come before us and helped pave the
+path. Just because one person's name may be in a repository title does not mean that only one person made that project
+happen. We can't hope to perfectly cover every single contribution that lead up to Archipelago, but we hope to honor
+them fairly.
### Path to the Archipelago
-Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to _MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as "Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository (as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
+
+Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a
+long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to
+_MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as
+"Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository
+(as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
## Running Archipelago
-For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
-If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our doc on [running Archipelago from source](docs/running%20from%20source.md).
+For most people, all you need to do is head over to
+the [releases page](https://github.com/ArchipelagoMW/Archipelago/releases), then download and run the appropriate
+installer, or AppImage for Linux-based systems.
+
+If you are a developer or are running on a platform with no compiled releases available, please see our doc on
+[running Archipelago from source](docs/running%20from%20source.md).
## Related Repositories
-This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
+
+This project makes use of multiple other projects. We wouldn't be here without these other repositories and the
+contributions of their developers, past and present.
* [z3randomizer](https://github.com/ArchipelagoMW/z3randomizer)
* [Enemizer](https://github.com/Ijwu/Enemizer)
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
## Contributing
-For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md)
+
+To contribute to Archipelago, including the WebHost, core program, or by adding a new game, see our
+[Contributing guidelines](/docs/contributing.md).
## FAQ
-For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/)
+
+For Frequently asked questions, please see the website's [FAQ Page](https://archipelago.gg/faq/en/).
## Code of Conduct
-Please refer to our [code of conduct.](/docs/code_of_conduct.md)
+
+Please refer to our [code of conduct](/docs/code_of_conduct.md).
diff --git a/SNIClient.py b/SNIClient.py
index 50b557e6d7cd..38fabcaab2e2 100644
--- a/SNIClient.py
+++ b/SNIClient.py
@@ -18,6 +18,7 @@
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
import Utils
+import settings
from Utils import async_start
from MultiServer import mark_raw
if typing.TYPE_CHECKING:
@@ -68,12 +69,11 @@ def connect_to_snes(self, snes_options: str = "") -> bool:
options = snes_options.split()
num_options = len(options)
- if num_options > 0:
- snes_device_number = int(options[0])
-
if num_options > 1:
snes_address = options[0]
snes_device_number = int(options[1])
+ elif num_options > 0:
+ snes_device_number = int(options[0])
self.ctx.snes_reconnect_address = None
if self.ctx.snes_connect_task:
@@ -86,6 +86,7 @@ def _cmd_snes_close(self) -> bool:
"""Close connection to a currently connected snes"""
self.ctx.snes_reconnect_address = None
self.ctx.cancel_snes_autoreconnect()
+ self.ctx.snes_state = SNESState.SNES_DISCONNECTED
if self.ctx.snes_socket and not self.ctx.snes_socket.closed:
async_start(self.ctx.snes_socket.close())
return True
@@ -208,12 +209,12 @@ def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
self.killing_player_task = asyncio.create_task(deathlink_kill_player(self))
super(SNIContext, self).on_deathlink(data)
- async def handle_deathlink_state(self, currently_dead: bool) -> None:
+ async def handle_deathlink_state(self, currently_dead: bool, death_text: str = "") -> None:
# in this state we only care about triggering a death send
if self.death_state == DeathState.alive:
if currently_dead:
self.death_state = DeathState.dead
- await self.send_death()
+ await self.send_death(death_text)
# in this state we care about confirming a kill, to move state to dead
elif self.death_state == DeathState.killing_player:
# this is being handled in deathlink_kill_player(ctx) already
@@ -243,6 +244,9 @@ def on_package(self, cmd: str, args: typing.Dict[str, typing.Any]) -> None:
# Once the games handled by SNIClient gets made to be remote items,
# this will no longer be needed.
async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
+
+ if self.client_handler is not None:
+ self.client_handler.on_package(self, cmd, args)
def run_gui(self) -> None:
from kvui import GameManager
@@ -282,7 +286,7 @@ class SNESState(enum.IntEnum):
def launch_sni() -> None:
- sni_path = Utils.get_options()["sni_options"]["sni_path"]
+ sni_path = settings.get_settings().sni_options.sni_path
if not os.path.isdir(sni_path):
sni_path = Utils.local_path(sni_path)
@@ -566,8 +570,6 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int,
try:
for address, data in write_list:
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
- # REVIEW: above: `if snes_socket is None: return False`
- # Does it need to be checked again?
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data)
@@ -635,7 +637,13 @@ async def game_watcher(ctx: SNIContext) -> None:
if not ctx.client_handler:
continue
- rom_validated = await ctx.client_handler.validate_rom(ctx)
+ try:
+ rom_validated = await ctx.client_handler.validate_rom(ctx)
+ except Exception as e:
+ snes_logger.error(f"An error occurred, see logs for details: {e}")
+ text_file_logger = logging.getLogger()
+ text_file_logger.exception(e)
+ rom_validated = False
if not rom_validated or (ctx.auth and ctx.auth != ctx.rom):
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
@@ -651,12 +659,17 @@ async def game_watcher(ctx: SNIContext) -> None:
perf_counter = time.perf_counter()
- await ctx.client_handler.game_watcher(ctx)
+ try:
+ await ctx.client_handler.game_watcher(ctx)
+ except Exception as e:
+ snes_logger.error(f"An error occurred, see logs for details: {e}")
+ text_file_logger = logging.getLogger()
+ text_file_logger.exception(e)
+ await snes_disconnect(ctx)
async def run_game(romfile: str) -> None:
- auto_start = typing.cast(typing.Union[bool, str],
- Utils.get_options()["sni_options"].get("snes_rom_start", True))
+ auto_start = settings.get_settings().sni_options.snes_rom_start
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
@@ -722,6 +735,6 @@ async def main() -> None:
if __name__ == '__main__':
- colorama.init()
+ colorama.just_fix_windows_console()
asyncio.run(main())
colorama.deinit()
diff --git a/Starcraft2Client.py b/Starcraft2Client.py
deleted file mode 100644
index cdcdb39a0b44..000000000000
--- a/Starcraft2Client.py
+++ /dev/null
@@ -1,1049 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import copy
-import ctypes
-import logging
-import multiprocessing
-import os.path
-import re
-import sys
-import typing
-import queue
-import zipfile
-import io
-from pathlib import Path
-
-# CommonClient import first to trigger ModuleUpdater
-from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
-from Utils import init_logging, is_windows
-
-if __name__ == "__main__":
- init_logging("SC2Client", exception_logger="Client")
-
-logger = logging.getLogger("Client")
-sc2_logger = logging.getLogger("Starcraft2")
-
-import nest_asyncio
-from worlds._sc2common import bot
-from worlds._sc2common.bot.data import Race
-from worlds._sc2common.bot.main import run_game
-from worlds._sc2common.bot.player import Bot
-from worlds.sc2wol import SC2WoLWorld
-from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
-from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
-from worlds.sc2wol.MissionTables import lookup_id_to_mission
-from worlds.sc2wol.Regions import MissionInfo
-
-import colorama
-from NetUtils import ClientStatus, NetworkItem, RawJSONtoTextParser
-from MultiServer import mark_raw
-
-nest_asyncio.apply()
-max_bonus: int = 8
-victory_modulo: int = 100
-
-
-class StarcraftClientProcessor(ClientCommandProcessor):
- ctx: SC2Context
-
- def _cmd_difficulty(self, difficulty: str = "") -> bool:
- """Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal"""
- options = difficulty.split()
- num_options = len(options)
-
- if num_options > 0:
- difficulty_choice = options[0].lower()
- if difficulty_choice == "casual":
- self.ctx.difficulty_override = 0
- elif difficulty_choice == "normal":
- self.ctx.difficulty_override = 1
- elif difficulty_choice == "hard":
- self.ctx.difficulty_override = 2
- elif difficulty_choice == "brutal":
- self.ctx.difficulty_override = 3
- else:
- self.output("Unable to parse difficulty '" + options[0] + "'")
- return False
-
- self.output("Difficulty set to " + options[0])
- return True
-
- else:
- 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:
- """Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play
- the next mission in a chain the other player is doing."""
- self.ctx.missions_unlocked = True
- sc2_logger.info("Mission check has been disabled")
- return True
-
- def _cmd_play(self, mission_id: str = "") -> bool:
- """Start a Starcraft 2 mission"""
-
- options = mission_id.split()
- num_options = len(options)
-
- if num_options > 0:
- mission_number = int(options[0])
-
- self.ctx.play_mission(mission_number)
-
- else:
- sc2_logger.info(
- "Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.")
- return False
-
- return True
-
- def _cmd_available(self) -> bool:
- """Get what missions are currently available to play"""
-
- request_available_missions(self.ctx)
- return True
-
- def _cmd_unfinished(self) -> bool:
- """Get what missions are currently available to play and have not had all locations checked"""
-
- request_unfinished_missions(self.ctx)
- return True
-
- @mark_raw
- def _cmd_set_path(self, path: str = '') -> bool:
- """Manually set the SC2 install directory (if the automatic detection fails)."""
- if path:
- os.environ["SC2PATH"] = path
- is_mod_installed_correctly()
- return True
- else:
- sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
- return False
-
- def _cmd_download_data(self) -> bool:
- """Download the most recent release of the necessary files for playing SC2 with
- Archipelago. Will overwrite existing files."""
- if "SC2PATH" not in os.environ:
- check_game_install_path()
-
- if os.path.exists(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt"):
- with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "r") as f:
- current_ver = f.read()
- else:
- current_ver = None
-
- tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData',
- current_version=current_ver, force_download=True)
-
- if tempzip != '':
- try:
- 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:
- f.write(version)
- finally:
- os.remove(tempzip)
- else:
- sc2_logger.warning("Download aborted/failed. Read the log for more information.")
- return False
- return True
-
-
-class SC2Context(CommonContext):
- command_processor = StarcraftClientProcessor
- game = "Starcraft 2 Wings of Liberty"
- items_handling = 0b111
- difficulty = -1
- all_in_choice = 0
- mission_order = 0
- mission_req_table: typing.Dict[str, MissionInfo] = {}
- final_mission: int = 29
- announcements = queue.Queue()
- sc2_run_task: typing.Optional[asyncio.Task] = None
- missions_unlocked: bool = False # allow launching missions ignoring requirements
- current_tooltip = None
- last_loc_list = None
- difficulty_override = -1
- mission_id_to_location_ids: typing.Dict[int, typing.List[int]] = {}
- last_bot: typing.Optional[ArchipelagoBot] = None
-
- def __init__(self, *args, **kwargs):
- super(SC2Context, self).__init__(*args, **kwargs)
- self.raw_text_parser = RawJSONtoTextParser(self)
-
- async def server_auth(self, password_requested: bool = False):
- if password_requested and not self.password:
- await super(SC2Context, self).server_auth(password_requested)
- await self.get_username()
- await self.send_connect()
-
- def on_package(self, cmd: str, args: dict):
- if cmd in {"Connected"}:
- self.difficulty = args["slot_data"]["game_difficulty"]
- self.all_in_choice = args["slot_data"]["all_in_map"]
- slot_req_table = args["slot_data"]["mission_req"]
- # Maintaining backwards compatibility with older slot data
- self.mission_req_table = {
- mission: MissionInfo(
- **{field: value for field, value in mission_info.items() if field in MissionInfo._fields}
- )
- for mission, mission_info in slot_req_table.items()
- }
- self.mission_order = args["slot_data"].get("mission_order", 0)
- self.final_mission = args["slot_data"].get("final_mission", 29)
-
- self.build_location_to_mission_mapping()
-
- # Looks for the required maps and mods for SC2. Runs check_game_install_path.
- 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
- if "receiving" in args and self.slot_concerns_self(args["receiving"]):
- relevant = True
- # found in this world
- elif "item" in args and self.slot_concerns_self(args["item"].player):
- relevant = True
- # not related
- else:
- relevant = False
-
- if relevant:
- self.announcements.put(self.raw_text_parser(copy.deepcopy(args["data"])))
-
- super(SC2Context, self).on_print_json(args)
-
- def run_gui(self):
- from kvui import GameManager, HoverBehavior, ServerToolTip
- from kivy.app import App
- from kivy.clock import Clock
- from kivy.uix.tabbedpanel import TabbedPanelItem
- from kivy.uix.gridlayout import GridLayout
- from kivy.lang import Builder
- from kivy.uix.label import Label
- from kivy.uix.button import Button
- from kivy.uix.floatlayout import FloatLayout
- from kivy.properties import StringProperty
-
- class HoverableButton(HoverBehavior, Button):
- pass
-
- class MissionButton(HoverableButton):
- tooltip_text = StringProperty("Test")
- ctx: SC2Context
-
- def __init__(self, *args, **kwargs):
- super(HoverableButton, self).__init__(*args, **kwargs)
- self.layout = FloatLayout()
- self.popuplabel = ServerToolTip(text=self.text)
- self.layout.add_widget(self.popuplabel)
-
- def on_enter(self):
- self.popuplabel.text = self.tooltip_text
-
- if self.ctx.current_tooltip:
- App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
-
- if self.tooltip_text == "":
- self.ctx.current_tooltip = None
- else:
- App.get_running_app().root.add_widget(self.layout)
- self.ctx.current_tooltip = self.layout
-
- def on_leave(self):
- self.ctx.ui.clear_tooltip()
-
- @property
- def ctx(self) -> CommonContext:
- return App.get_running_app().ctx
-
- class MissionLayout(GridLayout):
- pass
-
- class MissionCategory(GridLayout):
- pass
-
- class SC2Manager(GameManager):
- logging_pairs = [
- ("Client", "Archipelago"),
- ("Starcraft2", "Starcraft2"),
- ]
- base_title = "Archipelago Starcraft 2 Client"
-
- mission_panel = None
- last_checked_locations = {}
- mission_id_to_button = {}
- launching: typing.Union[bool, int] = False # if int -> mission ID
- refresh_from_launching = True
- first_check = True
- ctx: SC2Context
-
- def __init__(self, ctx):
- super().__init__(ctx)
-
- def clear_tooltip(self):
- if self.ctx.current_tooltip:
- App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
-
- self.ctx.current_tooltip = None
-
- def build(self):
- container = super().build()
-
- panel = TabbedPanelItem(text="Starcraft 2 Launcher")
- self.mission_panel = panel.content = MissionLayout()
-
- self.tabs.add_widget(panel)
-
- Clock.schedule_interval(self.build_mission_table, 0.5)
-
- return container
-
- def build_mission_table(self, dt):
- if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or
- not self.refresh_from_launching)) or self.first_check:
- self.refresh_from_launching = True
-
- self.mission_panel.clear_widgets()
- if self.ctx.mission_req_table:
- self.last_checked_locations = self.ctx.checked_locations.copy()
- self.first_check = False
-
- self.mission_id_to_button = {}
- categories = {}
- available_missions, unfinished_missions = calc_unfinished_missions(self.ctx)
-
- # separate missions into categories
- for mission in self.ctx.mission_req_table:
- if not self.ctx.mission_req_table[mission].category in categories:
- categories[self.ctx.mission_req_table[mission].category] = []
-
- categories[self.ctx.mission_req_table[mission].category].append(mission)
-
- for category in categories:
- category_panel = MissionCategory()
- if category.startswith('_'):
- category_display_name = ''
- else:
- category_display_name = category
- category_panel.add_widget(
- Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1))
-
- for mission in categories[category]:
- text: str = mission
- tooltip: str = ""
- mission_id: int = self.ctx.mission_req_table[mission].id
- # Map has uncollected locations
- if mission in unfinished_missions:
- text = f"[color=6495ED]{text}[/color]"
- elif mission in available_missions:
- text = f"[color=FFFFFF]{text}[/color]"
- # Map requirements not met
- else:
- text = f"[color=a9a9a9]{text}[/color]"
- tooltip = f"Requires: "
- if self.ctx.mission_req_table[mission].required_world:
- tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for
- req_mission in
- self.ctx.mission_req_table[mission].required_world)
-
- if self.ctx.mission_req_table[mission].number:
- tooltip += " and "
- if self.ctx.mission_req_table[mission].number:
- tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed"
- remaining_location_names: typing.List[str] = [
- self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission)
- if loc in self.ctx.missing_locations]
-
- if mission_id == self.ctx.final_mission:
- if mission in available_missions:
- text = f"[color=FFBC95]{mission}[/color]"
- else:
- text = f"[color=D0C0BE]{mission}[/color]"
- if tooltip:
- tooltip += "\n"
- tooltip += "Final Mission"
-
- if remaining_location_names:
- if tooltip:
- tooltip += "\n"
- tooltip += f"Uncollected locations:\n"
- tooltip += "\n".join(remaining_location_names)
-
- mission_button = MissionButton(text=text, size_hint_y=None, height=50)
- mission_button.tooltip_text = tooltip
- mission_button.bind(on_press=self.mission_callback)
- self.mission_id_to_button[mission_id] = mission_button
- category_panel.add_widget(mission_button)
-
- category_panel.add_widget(Label(text=""))
- self.mission_panel.add_widget(category_panel)
-
- elif self.launching:
- self.refresh_from_launching = False
-
- self.mission_panel.clear_widgets()
- self.mission_panel.add_widget(Label(text="Launching Mission: " +
- lookup_id_to_mission[self.launching]))
- if self.ctx.ui:
- self.ctx.ui.clear_tooltip()
-
- def mission_callback(self, button):
- if not self.launching:
- mission_id: int = next(k for k, v in self.mission_id_to_button.items() if v == button)
- self.ctx.play_mission(mission_id)
- self.launching = mission_id
- Clock.schedule_once(self.finish_launching, 10)
-
- def finish_launching(self, dt):
- self.launching = False
-
- self.ui = SC2Manager(self)
- self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
- import pkgutil
- data = pkgutil.get_data(SC2WoLWorld.__module__, "Starcraft2.kv").decode()
- Builder.load_string(data)
-
- async def shutdown(self):
- await super(SC2Context, self).shutdown()
- if self.last_bot:
- self.last_bot.want_close = True
- if self.sc2_run_task:
- self.sc2_run_task.cancel()
-
- def play_mission(self, mission_id: int):
- if self.missions_unlocked or \
- is_mission_available(self, mission_id):
- if self.sc2_run_task:
- if not self.sc2_run_task.done():
- sc2_logger.warning("Starcraft 2 Client is still running!")
- self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task
- if self.slot is None:
- sc2_logger.warning("Launching Mission without Archipelago authentication, "
- "checks will not be registered to server.")
- self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id),
- name="Starcraft 2 Launch")
- else:
- sc2_logger.info(
- f"{lookup_id_to_mission[mission_id]} is not currently unlocked. "
- f"Use /unfinished or /available to see what is available.")
-
- def build_location_to_mission_mapping(self):
- mission_id_to_location_ids: typing.Dict[int, typing.Set[int]] = {
- mission_info.id: set() for mission_info in self.mission_req_table.values()
- }
-
- for loc in self.server_locations:
- mission_id, objective = divmod(loc - SC2WOL_LOC_ID_OFFSET, victory_modulo)
- mission_id_to_location_ids[mission_id].add(objective)
- self.mission_id_to_location_ids = {mission_id: sorted(objectives) for mission_id, objectives in
- mission_id_to_location_ids.items()}
-
- def locations_for_mission(self, mission: str):
- mission_id: int = self.mission_req_table[mission].id
- objectives = self.mission_id_to_location_ids[self.mission_req_table[mission].id]
- for objective in objectives:
- yield SC2WOL_LOC_ID_OFFSET + mission_id * 100 + objective
-
-
-async def main():
- multiprocessing.freeze_support()
- parser = get_base_parser()
- parser.add_argument('--name', default=None, help="Slot Name to connect as.")
- args = parser.parse_args()
-
- ctx = SC2Context(args.connect, args.password)
- ctx.auth = args.name
- if ctx.server_task is None:
- ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
-
- if gui_enabled:
- ctx.run_gui()
- ctx.run_cli()
-
- await ctx.exit_event.wait()
-
- await ctx.shutdown()
-
-
-maps_table = [
- "ap_traynor01", "ap_traynor02", "ap_traynor03",
- "ap_thanson01", "ap_thanson02", "ap_thanson03a", "ap_thanson03b",
- "ap_ttychus01", "ap_ttychus02", "ap_ttychus03", "ap_ttychus04", "ap_ttychus05",
- "ap_ttosh01", "ap_ttosh02", "ap_ttosh03a", "ap_ttosh03b",
- "ap_thorner01", "ap_thorner02", "ap_thorner03", "ap_thorner04", "ap_thorner05s",
- "ap_tzeratul01", "ap_tzeratul02", "ap_tzeratul03", "ap_tzeratul04",
- "ap_tvalerian01", "ap_tvalerian02a", "ap_tvalerian02b", "ap_tvalerian03"
-]
-
-wol_default_categories = [
- "Mar Sara", "Mar Sara", "Mar Sara", "Colonist", "Colonist", "Colonist", "Colonist",
- "Artifact", "Artifact", "Artifact", "Artifact", "Artifact", "Covert", "Covert", "Covert", "Covert",
- "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
- "Char", "Char", "Char", "Char"
-]
-wol_default_category_names = [
- "Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char"
-]
-
-
-def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]:
- network_item: NetworkItem
- accumulators: typing.List[int] = [0 for _ in type_flaggroups]
-
- for network_item in items:
- name: str = lookup_id_to_name[network_item.item]
- item_data: ItemData = item_table[name]
-
- # exists exactly once
- if item_data.quantity == 1:
- accumulators[type_flaggroups[item_data.type]] |= 1 << item_data.number
-
- # exists multiple times
- elif item_data.type == "Upgrade":
- accumulators[type_flaggroups[item_data.type]] += 1 << item_data.number
-
- # sum
- else:
- accumulators[type_flaggroups[item_data.type]] += item_data.number
-
- return accumulators
-
-
-def calc_difficulty(difficulty):
- if difficulty == 0:
- return 'C'
- elif difficulty == 1:
- return 'N'
- elif difficulty == 2:
- return 'H'
- elif difficulty == 3:
- return 'B'
-
- return 'X'
-
-
-async def starcraft_launch(ctx: SC2Context, mission_id: int):
- sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.")
-
- with DllDirectory(None):
- run_game(bot.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id),
- name="Archipelago", fullscreen=True)], realtime=True)
-
-
-class ArchipelagoBot(bot.bot_ai.BotAI):
- game_running: bool = False
- mission_completed: bool = False
- boni: typing.List[bool]
- setup_done: bool
- ctx: SC2Context
- mission_id: int
- want_close: bool = False
- can_read_game = False
-
- last_received_update: int = 0
-
- def __init__(self, ctx: SC2Context, mission_id):
- self.setup_done = False
- self.ctx = ctx
- self.ctx.last_bot = self
- self.mission_id = mission_id
- self.boni = [False for _ in range(max_bonus)]
-
- super(ArchipelagoBot, self).__init__()
-
- async def on_step(self, iteration: int):
- if self.want_close:
- self.want_close = False
- await self._client.leave()
- return
- game_state = 0
- if not self.setup_done:
- self.setup_done = True
- start_items = calculate_items(self.ctx.items_received)
- if self.ctx.difficulty_override >= 0:
- difficulty = calc_difficulty(self.ctx.difficulty_override)
- else:
- difficulty = calc_difficulty(self.ctx.difficulty)
- await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format(
- difficulty,
- start_items[0], start_items[1], start_items[2], start_items[3], start_items[4],
- start_items[5], start_items[6], start_items[7], start_items[8], start_items[9],
- self.ctx.all_in_choice, start_items[10]))
- self.last_received_update = len(self.ctx.items_received)
-
- else:
- if not self.ctx.announcements.empty():
- message = self.ctx.announcements.get(timeout=1)
- await self.chat_send("SendMessage " + message)
- self.ctx.announcements.task_done()
-
- # Archipelago reads the health
- for unit in self.all_own_units():
- if unit.health_max == 38281:
- game_state = int(38281 - unit.health)
- self.can_read_game = True
-
- if iteration == 160 and not game_state & 1:
- await self.chat_send("SendMessage Warning: Archipelago unable to connect or has lost connection to " +
- "Starcraft 2 (This is likely a map issue)")
-
- if self.last_received_update < len(self.ctx.items_received):
- current_items = calculate_items(self.ctx.items_received)
- await self.chat_send("UpdateTech {} {} {} {} {} {} {} {}".format(
- current_items[0], current_items[1], current_items[2], current_items[3], current_items[4],
- current_items[5], current_items[6], current_items[7]))
- self.last_received_update = len(self.ctx.items_received)
-
- if game_state & 1:
- if not self.game_running:
- print("Archipelago Connected")
- self.game_running = True
-
- if self.can_read_game:
- if game_state & (1 << 1) and not self.mission_completed:
- if self.mission_id != self.ctx.final_mission:
- print("Mission Completed")
- await self.ctx.send_msgs(
- [{"cmd": 'LocationChecks',
- "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id]}])
- self.mission_completed = True
- else:
- print("Game Complete")
- await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}])
- self.mission_completed = True
-
- for x, completed in enumerate(self.boni):
- if not completed and game_state & (1 << (x + 2)):
- await self.ctx.send_msgs(
- [{"cmd": 'LocationChecks',
- "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id + x + 1]}])
- self.boni[x] = True
-
- else:
- await self.chat_send("LostConnection - Lost connection to game.")
-
-
-def request_unfinished_missions(ctx: SC2Context):
- if ctx.mission_req_table:
- message = "Unfinished Missions: "
- unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
- unfinished_locations = initialize_blank_mission_dict(ctx.mission_req_table)
-
- _, 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])}/"
- f"{sum(1 for _ in ctx.locations_for_mission(mission))}]",
- ctx, unfinished_locations, mission)
- for mission in unfinished_missions)
-
- if ctx.ui:
- ctx.ui.log_panels['All'].on_message_markup(message)
- ctx.ui.log_panels['Starcraft2'].on_message_markup(message)
- else:
- sc2_logger.info(message)
- else:
- sc2_logger.warning("No mission table found, you are likely not connected to a server.")
-
-
-def calc_unfinished_missions(ctx: SC2Context, unlocks=None):
- unfinished_missions = []
- locations_completed = []
-
- if not unlocks:
- unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
-
- available_missions = calc_available_missions(ctx, unlocks)
-
- for name in available_missions:
- objectives = set(ctx.locations_for_mission(name))
- if objectives:
- objectives_completed = ctx.checked_locations & objectives
- if len(objectives_completed) < len(objectives):
- unfinished_missions.append(name)
- locations_completed.append(objectives_completed)
-
- else: # infer that this is the final mission as it has no objectives
- unfinished_missions.append(name)
- locations_completed.append(-1)
-
- return available_missions, dict(zip(unfinished_missions, locations_completed))
-
-
-def is_mission_available(ctx: SC2Context, mission_id_to_check):
- unfinished_missions = calc_available_missions(ctx)
-
- return any(mission_id_to_check == ctx.mission_req_table[mission].id for mission in unfinished_missions)
-
-
-def mark_up_mission_name(ctx: SC2Context, mission, unlock_table):
- """Checks if the mission is required for game completion and adds '*' to the name to mark that."""
-
- if ctx.mission_req_table[mission].completion_critical:
- if ctx.ui:
- message = "[color=AF99EF]" + mission + "[/color]"
- else:
- message = "*" + mission + "*"
- else:
- message = mission
-
- if ctx.ui:
- unlocks = unlock_table[mission]
-
- if len(unlocks) > 0:
- pre_message = f"[ref={list(ctx.mission_req_table).index(mission)}|Unlocks: "
- pre_message += ", ".join(f"{unlock}({ctx.mission_req_table[unlock].id})" for unlock in unlocks)
- pre_message += f"]"
- message = pre_message + message + "[/ref]"
-
- return message
-
-
-def mark_up_objectives(message, ctx, unfinished_locations, mission):
- formatted_message = message
-
- if ctx.ui:
- locations = unfinished_locations[mission]
-
- pre_message = f"[ref={list(ctx.mission_req_table).index(mission) + 30}|"
- pre_message += " ".join(location for location in locations)
- pre_message += f"]"
- formatted_message = pre_message + message + "[/ref]"
-
- return formatted_message
-
-
-def request_available_missions(ctx: SC2Context):
- if ctx.mission_req_table:
- message = "Available Missions: "
-
- # Initialize mission unlock table
- unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
-
- missions = calc_available_missions(ctx, unlocks)
- message += \
- ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}"
- f"[{ctx.mission_req_table[mission].id}]"
- for mission in missions)
-
- if ctx.ui:
- ctx.ui.log_panels['All'].on_message_markup(message)
- ctx.ui.log_panels['Starcraft2'].on_message_markup(message)
- else:
- sc2_logger.info(message)
- else:
- sc2_logger.warning("No mission table found, you are likely not connected to a server.")
-
-
-def calc_available_missions(ctx: SC2Context, unlocks=None):
- available_missions = []
- missions_complete = 0
-
- # Get number of missions completed
- for loc in ctx.checked_locations:
- if loc % victory_modulo == 0:
- missions_complete += 1
-
- for name in ctx.mission_req_table:
- # Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips
- if unlocks:
- for unlock in ctx.mission_req_table[name].required_world:
- unlocks[list(ctx.mission_req_table)[unlock - 1]].append(name)
-
- if mission_reqs_completed(ctx, name, missions_complete):
- available_missions.append(name)
-
- return available_missions
-
-
-def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int):
- """Returns a bool signifying if the mission has all requirements complete and can be done
-
- Arguments:
- ctx -- instance of SC2Context
- locations_to_check -- the mission string name to check
- missions_complete -- an int of how many missions have been completed
- mission_path -- a list of missions that have already been checked
-"""
- if len(ctx.mission_req_table[mission_name].required_world) >= 1:
- # A check for when the requirements are being or'd
- or_success = False
-
- # Loop through required missions
- for req_mission in ctx.mission_req_table[mission_name].required_world:
- req_success = True
-
- # Check if required mission has been completed
- if not (ctx.mission_req_table[list(ctx.mission_req_table)[req_mission - 1]].id *
- victory_modulo + SC2WOL_LOC_ID_OFFSET) in ctx.checked_locations:
- if not ctx.mission_req_table[mission_name].or_requirements:
- return False
- else:
- req_success = False
-
- # Grid-specific logic (to avoid long path checks and infinite recursion)
- if ctx.mission_order in (3, 4):
- if req_success:
- return True
- else:
- if req_mission is ctx.mission_req_table[mission_name].required_world[-1]:
- return False
- else:
- continue
-
- # Recursively check required mission to see if it's requirements are met, in case !collect has been done
- # Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion
- if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
- if not ctx.mission_req_table[mission_name].or_requirements:
- return False
- else:
- req_success = False
-
- # If requirement check succeeded mark or as satisfied
- if ctx.mission_req_table[mission_name].or_requirements and req_success:
- or_success = True
-
- if ctx.mission_req_table[mission_name].or_requirements:
- # Return false if or requirements not met
- if not or_success:
- return False
-
- # Check number of missions
- if missions_complete >= ctx.mission_req_table[mission_name].number:
- return True
- else:
- return False
- else:
- return True
-
-
-def initialize_blank_mission_dict(location_table):
- unlocks = {}
-
- for mission in list(location_table):
- unlocks[mission] = []
-
- return unlocks
-
-
-def check_game_install_path() -> bool:
- # First thing: go to the default location for ExecuteInfo.
- # An exception for Windows is included because it's very difficult to find ~\Documents if the user moved it.
- if is_windows:
- # The next five lines of utterly inscrutable code are brought to you by copy-paste from Stack Overflow.
- # https://stackoverflow.com/questions/6227590/finding-the-users-my-documents-path/30924555#
- import ctypes.wintypes
- CSIDL_PERSONAL = 5 # My Documents
- SHGFP_TYPE_CURRENT = 0 # Get current, not default value
-
- buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
- ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf)
- documentspath = buf.value
- einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt"))
- else:
- einfo = str(bot.paths.get_home() / Path(bot.paths.USERPATH[bot.paths.PF]))
-
- # Check if the file exists.
- if os.path.isfile(einfo):
-
- # Open the file and read it, picking out the latest executable's path.
- with open(einfo) as f:
- content = f.read()
- if content:
- try:
- base = re.search(r" = (.*)Versions", content).group(1)
- except AttributeError:
- sc2_logger.warning(f"Found {einfo}, but it was empty. Run SC2 through the Blizzard launcher, then "
- f"try again.")
- return False
- if os.path.exists(base):
- executable = bot.paths.latest_executeble(Path(base).expanduser() / "Versions")
-
- # Finally, check the path for an actual executable.
- # If we find one, great. Set up the SC2PATH.
- if os.path.isfile(executable):
- sc2_logger.info(f"Found an SC2 install at {base}!")
- sc2_logger.debug(f"Latest executable at {executable}.")
- os.environ["SC2PATH"] = base
- sc2_logger.debug(f"SC2PATH set to {base}.")
- return True
- else:
- sc2_logger.warning(f"We may have found an SC2 install at {base}, but couldn't find {executable}.")
- else:
- sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
- else:
- sc2_logger.warning(f"Couldn't find {einfo}. Run SC2 through the Blizzard launcher, then try again. "
- f"If that fails, please run /set_path with your SC2 install directory.")
- return False
-
-
-def is_mod_installed_correctly() -> bool:
- """Searches for all required files."""
- if "SC2PATH" not in os.environ:
- check_game_install_path()
-
- mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign')
- modfile = os.environ["SC2PATH"] / Path("Mods/Archipelago.SC2Mod")
- wol_required_maps = [
- "ap_thanson01.SC2Map", "ap_thanson02.SC2Map", "ap_thanson03a.SC2Map", "ap_thanson03b.SC2Map",
- "ap_thorner01.SC2Map", "ap_thorner02.SC2Map", "ap_thorner03.SC2Map", "ap_thorner04.SC2Map", "ap_thorner05s.SC2Map",
- "ap_traynor01.SC2Map", "ap_traynor02.SC2Map", "ap_traynor03.SC2Map",
- "ap_ttosh01.SC2Map", "ap_ttosh02.SC2Map", "ap_ttosh03a.SC2Map", "ap_ttosh03b.SC2Map",
- "ap_ttychus01.SC2Map", "ap_ttychus02.SC2Map", "ap_ttychus03.SC2Map", "ap_ttychus04.SC2Map", "ap_ttychus05.SC2Map",
- "ap_tvalerian01.SC2Map", "ap_tvalerian02a.SC2Map", "ap_tvalerian02b.SC2Map", "ap_tvalerian03.SC2Map",
- "ap_tzeratul01.SC2Map", "ap_tzeratul02.SC2Map", "ap_tzeratul03.SC2Map", "ap_tzeratul04.SC2Map"
- ]
- needs_files = False
-
- # Check for maps.
- missing_maps = []
- for mapfile in wol_required_maps:
- if not os.path.isfile(mapdir / mapfile):
- missing_maps.append(mapfile)
- if len(missing_maps) >= 19:
- sc2_logger.warning(f"All map files missing from {mapdir}.")
- needs_files = True
- elif len(missing_maps) > 0:
- for map in missing_maps:
- sc2_logger.debug(f"Missing {map} from {mapdir}.")
- sc2_logger.warning(f"Missing {len(missing_maps)} map files.")
- needs_files = True
- else: # Must be no maps missing
- sc2_logger.info(f"All maps found in {mapdir}.")
-
- # Check for mods.
- if os.path.isfile(modfile):
- sc2_logger.info(f"Archipelago mod found at {modfile}.")
- else:
- sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.")
- needs_files = True
-
- # Final verdict.
- if needs_files:
- sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.")
- return False
- else:
- return True
-
-
-class DllDirectory:
- # Credit to Black Sliver for this code.
- # More info: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw
- _old: typing.Optional[str] = None
- _new: typing.Optional[str] = None
-
- def __init__(self, new: typing.Optional[str]):
- self._new = new
-
- def __enter__(self):
- old = self.get()
- if self.set(self._new):
- self._old = old
-
- def __exit__(self, *args):
- if self._old is not None:
- self.set(self._old)
-
- @staticmethod
- def get() -> typing.Optional[str]:
- if sys.platform == "win32":
- n = ctypes.windll.kernel32.GetDllDirectoryW(0, None)
- buf = ctypes.create_unicode_buffer(n)
- ctypes.windll.kernel32.GetDllDirectoryW(n, buf)
- return buf.value
- # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
- return None
-
- @staticmethod
- def set(s: typing.Optional[str]) -> bool:
- if sys.platform == "win32":
- return ctypes.windll.kernel32.SetDllDirectoryW(s) != 0
- # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
- return False
-
-
-def download_latest_release_zip(owner: str, repo: str, current_version: str = None, force_download=False) -> (str, str):
- """Downloads the latest release of a GitHub repo to the current directory as a .zip file."""
- import requests
-
- headers = {"Accept": 'application/vnd.github.v3+json'}
- url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
-
- r1 = requests.get(url, headers=headers)
- if r1.status_code == 200:
- latest_version = r1.json()["tag_name"]
- sc2_logger.info(f"Latest version: {latest_version}.")
- else:
- sc2_logger.warning(f"Status code: {r1.status_code}")
- sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.")
- sc2_logger.warning(f"text: {r1.text}")
- return "", current_version
-
- if (force_download is False) and (current_version == latest_version):
- sc2_logger.info("Latest version already installed.")
- return "", current_version
-
- sc2_logger.info(f"Attempting to download version {latest_version} of {repo}.")
- download_url = r1.json()["assets"][0]["browser_download_url"]
-
- r2 = requests.get(download_url, headers=headers)
- 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.")
- return f"{repo}.zip", latest_version
- else:
- sc2_logger.warning(f"Status code: {r2.status_code}")
- sc2_logger.warning("Download failed.")
- sc2_logger.warning(f"text: {r2.text}")
- return "", current_version
-
-
-def is_mod_update_available(owner: str, repo: str, current_version: str) -> bool:
- import requests
-
- headers = {"Accept": 'application/vnd.github.v3+json'}
- url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
-
- r1 = requests.get(url, headers=headers)
- if r1.status_code == 200:
- latest_version = r1.json()["tag_name"]
- if current_version != latest_version:
- return True
- else:
- return False
-
- else:
- sc2_logger.warning(f"Failed to reach GitHub while checking for updates.")
- sc2_logger.warning(f"Status code: {r1.status_code}")
- sc2_logger.warning(f"text: {r1.text}")
- return False
-
-
-if __name__ == '__main__':
- colorama.init()
- asyncio.run(main())
- colorama.deinit()
diff --git a/UndertaleClient.py b/UndertaleClient.py
index e0ec642b1fd4..1c522fac924d 100644
--- a/UndertaleClient.py
+++ b/UndertaleClient.py
@@ -27,33 +27,33 @@ def _cmd_resync(self):
self.ctx.syncing = True
def _cmd_patch(self):
- """Patch the game."""
+ """Patch the game. Only use this command if /auto_patch fails."""
if isinstance(self.ctx, UndertaleContext):
- os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
+ os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
self.ctx.patch_game()
self.output("Patched.")
def _cmd_savepath(self, directory: str):
- """Redirect to proper save data folder. (Use before connecting!)"""
+ """Redirect to proper save data folder. This is necessary for Linux users to use before connecting."""
if isinstance(self.ctx, UndertaleContext):
- UndertaleContext.save_game_folder = directory
- self.output("Changed to the following directory: " + directory)
+ self.ctx.save_game_folder = directory
+ self.output("Changed to the following directory: " + self.ctx.save_game_folder)
@mark_raw
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
"""Patch the game automatically."""
if isinstance(self.ctx, UndertaleContext):
- os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True)
+ os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
tempInstall = steaminstall
if not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None
if tempInstall is None:
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
- if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
+ if not os.path.exists(tempInstall):
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
elif not os.path.exists(tempInstall):
tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"
- if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"):
+ if not os.path.exists(tempInstall):
tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale"
if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")):
self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder."
@@ -61,13 +61,13 @@ def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
else:
for file_name in os.listdir(tempInstall):
if file_name != "steam_api.dll":
- shutil.copy(tempInstall+"\\"+file_name,
- os.getcwd() + "\\Undertale\\" + file_name)
+ shutil.copy(os.path.join(tempInstall, file_name),
+ Utils.user_path("Undertale", file_name))
self.ctx.patch_game()
self.output("Patching successful!")
def _cmd_online(self):
- """Makes you no longer able to see other Undertale players."""
+ """Toggles seeing other Undertale players."""
if isinstance(self.ctx, UndertaleContext):
self.ctx.update_online_mode(not ("Online" in self.ctx.tags))
if "Online" in self.ctx.tags:
@@ -99,6 +99,7 @@ class UndertaleContext(CommonContext):
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.pieces_needed = 0
+ self.finished_game = False
self.game = "Undertale"
self.got_deathlink = False
self.syncing = False
@@ -110,13 +111,13 @@ def __init__(self, server_address, password):
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
def patch_game(self):
- with open(os.getcwd() + "/Undertale/data.win", "rb") as f:
+ with open(Utils.user_path("Undertale", "data.win"), "rb") as f:
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
- with open(os.getcwd() + "/Undertale/data.win", "wb") as f:
+ with open(Utils.user_path("Undertale", "data.win"), "wb") as f:
f.write(patchedFile)
- os.makedirs(name=os.getcwd() + "\\Undertale\\" + "Custom Sprites", exist_ok=True)
- with open(os.path.expandvars(os.getcwd() + "\\Undertale\\" + "Custom Sprites\\" +
- "Which Character.txt"), "w") as f:
+ os.makedirs(name=Utils.user_path("Undertale", "Custom Sprites"), exist_ok=True)
+ with open(os.path.expandvars(Utils.user_path("Undertale", "Custom Sprites",
+ "Which Character.txt")), "w") as f:
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
"line other than this one.\n", "frisk"])
f.close()
@@ -239,8 +240,6 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
for ss in set(args["checked_locations"]):
f.write(str(ss-12000)+"\n")
f.close()
- message = [{"cmd": "LocationChecks", "locations": [79067]}]
- await ctx.send_msgs(message)
elif cmd == "LocationInfo":
for l in args["locations"]:
locationid = l.location
@@ -248,8 +247,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
toDraw = ""
for i in range(20):
- if i < len(str(ctx.item_names[l.item])):
- toDraw += str(ctx.item_names[l.item])[i]
+ if i < len(str(ctx.item_names.lookup_in_game(l.item))):
+ toDraw += str(ctx.item_names.lookup_in_game(l.item))[i]
else:
break
f.write(toDraw)
@@ -386,7 +385,7 @@ async def multi_watcher(ctx: UndertaleContext):
for root, dirs, files in os.walk(path):
for file in files:
if "spots.mine" in file and "Online" in ctx.tags:
- with open(root + "/" + file, "r") as mine:
+ with open(os.path.join(root, file), "r") as mine:
this_x = mine.readline()
this_y = mine.readline()
this_room = mine.readline()
@@ -409,7 +408,7 @@ async def game_watcher(ctx: UndertaleContext):
for root, dirs, files in os.walk(path):
for file in files:
if ".item" in file:
- os.remove(root+"/"+file)
+ os.remove(os.path.join(root, file))
sync_msg = [{"cmd": "Sync"}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
@@ -425,36 +424,34 @@ async def game_watcher(ctx: UndertaleContext):
for root, dirs, files in os.walk(path):
for file in files:
if "DontBeMad.mad" in file:
- os.remove(root+"/"+file)
+ os.remove(os.path.join(root, file))
if "DeathLink" in ctx.tags:
await ctx.send_death()
if "scout" == file:
sending = []
try:
- with open(root+"/"+file, "r") as f:
+ with open(os.path.join(root, file), "r") as f:
lines = f.readlines()
for l in lines:
if ctx.server_locations.__contains__(int(l)+12000):
sending = sending + [int(l.rstrip('\n'))+12000]
+ finally:
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending,
"create_as_hint": int(2)}])
- finally:
- os.remove(root+"/"+file)
+ os.remove(os.path.join(root, file))
if "check.spot" in file:
sending = []
try:
- with open(root+"/"+file, "r") as f:
+ with open(os.path.join(root, file), "r") as f:
lines = f.readlines()
for l in lines:
sending = sending+[(int(l.rstrip('\n')))+12000]
- message = [{"cmd": "LocationChecks", "locations": sending}]
- await ctx.send_msgs(message)
finally:
- pass
+ await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}])
if "victory" in file and str(ctx.route) in file:
victory = True
if ".playerspot" in file and "Online" not in ctx.tags:
- os.remove(root+"/"+file)
+ os.remove(os.path.join(root, file))
if "victory" in file:
if str(ctx.route) == "all_routes":
if "neutral" in file and ctx.completed_routes["neutral"] != 1:
@@ -503,7 +500,7 @@ async def _main():
import colorama
- colorama.init()
+ colorama.just_fix_windows_console()
asyncio.run(_main())
colorama.deinit()
diff --git a/Utils.py b/Utils.py
index f3e748d1cc09..63718bef2623 100644
--- a/Utils.py
+++ b/Utils.py
@@ -1,10 +1,12 @@
from __future__ import annotations
import asyncio
+import concurrent.futures
import json
import typing
import builtins
import os
+import itertools
import subprocess
import sys
import pickle
@@ -13,25 +15,28 @@
import collections
import importlib
import logging
+import warnings
+from argparse import Namespace
from settings import Settings, get_settings
-from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
-from yaml import load, load_all, dump, SafeLoader
+from time import sleep
+from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
+from yaml import load, load_all, dump
try:
- from yaml import CLoader as UnsafeLoader
- from yaml import CDumper as Dumper
+ from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
except ImportError:
- from yaml import Loader as UnsafeLoader
- from yaml import Dumper
+ from yaml import Loader as UnsafeLoader, SafeLoader, Dumper
if typing.TYPE_CHECKING:
import tkinter
import pathlib
+ from BaseClasses import Region
+ import multiprocessing
def tuplize_version(version: str) -> Version:
- return Version(*(int(piece, 10) for piece in version.split(".")))
+ return Version(*(int(piece) for piece in version.split(".")))
class Version(typing.NamedTuple):
@@ -43,7 +48,7 @@ def as_simple_string(self) -> str:
return ".".join(str(item) for item in self)
-__version__ = "0.4.2"
+__version__ = "0.6.6"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -70,6 +75,8 @@ def snes_to_pc(value: int) -> int:
RetType = typing.TypeVar("RetType")
+S = typing.TypeVar("S")
+T = typing.TypeVar("T")
def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]:
@@ -87,6 +94,32 @@ def _wrap() -> RetType:
return _wrap
+def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[S, T], RetType]:
+ """Specialized cache for self + 1 arg. Does not keep global ref to self and skips building a dict key tuple."""
+
+ assert function.__code__.co_argcount == 2, "Can only cache 2 argument functions with this cache."
+
+ cache_name = f"__cache_{function.__name__}__"
+
+ @functools.wraps(function)
+ def wrap(self: S, arg: T) -> RetType:
+ cache: Optional[Dict[T, RetType]] = getattr(self, cache_name, None)
+ if cache is None:
+ res = function(self, arg)
+ setattr(self, cache_name, {arg: res})
+ return res
+ try:
+ return cache[arg]
+ except KeyError:
+ res = function(self, arg)
+ cache[arg] = res
+ return res
+
+ wrap.__defaults__ = function.__defaults__
+
+ return wrap
+
+
def is_frozen() -> bool:
return typing.cast(bool, getattr(sys, 'frozen', False))
@@ -107,8 +140,11 @@ 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__") and os.path.isfile(__main__.__file__):
+ if globals().get("__file__") and os.path.isfile(__file__):
# we are running in a normal Python environment
+ local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
+ elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
+ # we are running in a normal Python environment, but AP was imported weirdly
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
else:
# pray
@@ -122,7 +158,18 @@ def home_path(*path: str) -> str:
if hasattr(home_path, 'cached_path'):
pass
elif sys.platform.startswith('linux'):
- home_path.cached_path = os.path.expanduser('~/Archipelago')
+ xdg_data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
+ home_path.cached_path = xdg_data_home + '/Archipelago'
+ if not os.path.isdir(home_path.cached_path):
+ legacy_home_path = os.path.expanduser('~/Archipelago')
+ if os.path.isdir(legacy_home_path):
+ os.renames(legacy_home_path, home_path.cached_path)
+ os.symlink(home_path.cached_path, legacy_home_path)
+ else:
+ os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
+ elif sys.platform == 'darwin':
+ import platformdirs
+ home_path.cached_path = platformdirs.user_data_dir("Archipelago", False)
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
else:
# not implemented
@@ -135,7 +182,7 @@ def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, "cached_path"):
pass
- elif os.access(local_path(), os.W_OK):
+ elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()):
user_path.cached_path = local_path()
else:
user_path.cached_path = home_path()
@@ -143,12 +190,16 @@ def user_path(*path: str) -> str:
if user_path.cached_path != local_path():
import filecmp
if not os.path.exists(user_path("manifest.json")) or \
+ not os.path.exists(local_path("manifest.json")) or \
not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True):
import shutil
- for dn in ("Players", "data/sprites"):
+ for dn in ("Players", "data/sprites", "data/lua"):
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
- for fn in ("manifest.json",):
- shutil.copy2(local_path(fn), user_path(fn))
+ if not os.path.exists(local_path("manifest.json")):
+ warnings.warn(f"Upgrading {user_path()} from something that is not a proper install")
+ else:
+ shutil.copy2(local_path("manifest.json"), user_path("manifest.json"))
+ os.makedirs(user_path("worlds"), exist_ok=True)
return os.path.join(user_path.cached_path, *path)
@@ -167,7 +218,7 @@ def cache_path(*path: str) -> str:
def output_path(*path: str) -> str:
if hasattr(output_path, 'cached_path'):
return os.path.join(output_path.cached_path, *path)
- output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
+ output_path.cached_path = user_path(get_settings()["general_options"]["output_path"])
path = os.path.join(output_path.cached_path, *path)
os.makedirs(os.path.dirname(path), exist_ok=True)
return path
@@ -175,11 +226,17 @@ def output_path(*path: str) -> str:
def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
if is_windows:
- os.startfile(filename)
+ os.startfile(filename) # type: ignore
else:
from shutil import which
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
- subprocess.call([open_command, filename])
+ assert open_command, "Didn't find program for open_file! Please report this together with system details."
+
+ env = os.environ
+ if "LD_LIBRARY_PATH" in env:
+ env = env.copy()
+ del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
+ subprocess.call([open_command, filename], env=env)
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
@@ -191,6 +248,9 @@ def construct_mapping(self, node, deep=False):
if key in mapping:
logging.error(f"YAML duplicates sanity check failed{key_node.start_mark}")
raise KeyError(f"Duplicate key {key} found in YAML. Already found keys: {mapping}.")
+ if (str(key).startswith("+") and (str(key)[1:] in mapping)) or (f"+{key}" in mapping):
+ logging.error(f"YAML merge duplicates sanity check failed{key_node.start_mark}")
+ raise KeyError(f"Equivalent key {key} found in YAML. Already found keys: {mapping}.")
mapping.add(key)
return super().construct_mapping(node, deep)
@@ -214,7 +274,13 @@ def get_cert_none_ssl_context():
def get_public_ipv4() -> str:
import socket
import urllib.request
- ip = socket.gethostbyname(socket.gethostname())
+ try:
+ ip = socket.gethostbyname(socket.gethostname())
+ except socket.gaierror:
+ # if hostname or resolvconf is not set up properly, this may fail
+ warnings.warn("Could not resolve own hostname, falling back to 127.0.0.1")
+ ip = "127.0.0.1"
+
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip()
@@ -232,7 +298,13 @@ def get_public_ipv4() -> str:
def get_public_ipv6() -> str:
import socket
import urllib.request
- ip = socket.gethostbyname(socket.gethostname())
+ try:
+ ip = socket.gethostbyname(socket.gethostname())
+ except socket.gaierror:
+ # if hostname or resolvconf is not set up properly, this may fail
+ warnings.warn("Could not resolve own hostname, falling back to ::1")
+ ip = "::1"
+
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
@@ -242,32 +314,28 @@ def get_public_ipv6() -> str:
return ip
-OptionsType = Settings # TODO: remove ~2 versions after 0.4.1
-
-
-@cache_argsless
-def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1
- return Settings(None)
-
+def get_options() -> Settings:
+ deprecate("Utils.get_options() is deprecated. Use the settings API instead.")
+ return get_settings()
-get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported
-
-def persistent_store(category: str, key: typing.Any, value: typing.Any):
+def persistent_store(category: str, key: str, value: typing.Any, force_store: bool = False):
+ storage = persistent_load()
+ if not force_store and category in storage and key in storage[category] and storage[category][key] == value:
+ return # no changes necessary
+ category_dict = storage.setdefault(category, {})
+ category_dict[key] = value
path = user_path("_persistent_storage.yaml")
- storage: dict = persistent_load()
- category = storage.setdefault(category, {})
- category[key] = value
with open(path, "wt") as f:
f.write(dump(storage, Dumper=Dumper))
-def persistent_load() -> typing.Dict[str, dict]:
- storage = getattr(persistent_load, "storage", None)
+def persistent_load() -> Dict[str, Dict[str, Any]]:
+ storage: Union[Dict[str, Dict[str, Any]], None] = getattr(persistent_load, "storage", None)
if storage:
return storage
path = user_path("_persistent_storage.yaml")
- storage: dict = {}
+ storage = {}
if os.path.exists(path):
try:
with open(path, "r") as f:
@@ -276,7 +344,7 @@ def persistent_load() -> typing.Dict[str, dict]:
logging.debug(f"Could not read store: {e}")
if storage is None:
storage = {}
- persistent_load.storage = storage
+ setattr(persistent_load, "storage", storage)
return storage
@@ -319,20 +387,51 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
logging.debug(f"Could not store data package: {e}")
-def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]:
- adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
+def get_default_adjuster_settings(game_name: str) -> Namespace:
+ import LttPAdjuster
+ adjuster_settings = Namespace()
+ if game_name == LttPAdjuster.GAME_ALTTP:
+ return LttPAdjuster.get_argparser().parse_known_args(args=[])[0]
+
return adjuster_settings
+def get_adjuster_settings_no_defaults(game_name: str) -> Namespace:
+ return persistent_load().get("adjuster", {}).get(game_name, Namespace())
+
+
+def get_adjuster_settings(game_name: str) -> Namespace:
+ adjuster_settings = get_adjuster_settings_no_defaults(game_name)
+ default_settings = get_default_adjuster_settings(game_name)
+
+ # Fill in any arguments from the argparser that we haven't seen before
+ return Namespace(**vars(adjuster_settings), **{
+ k: v for k, v in vars(default_settings).items() if k not in vars(adjuster_settings)
+ })
+
+
@cache_argsless
def get_unique_identifier():
- uuid = persistent_load().get("client", {}).get("uuid", None)
+ common_path = cache_path("common.json")
+ try:
+ with open(common_path) as f:
+ common_file = json.load(f)
+ uuid = common_file.get("uuid", None)
+ except FileNotFoundError:
+ common_file = {}
+ uuid = None
+
if uuid:
return uuid
- import uuid
- uuid = uuid.getnode()
- persistent_store("client", "uuid", uuid)
+ from uuid import uuid4
+ uuid = str(uuid4())
+ common_file["uuid"] = uuid
+
+ cache_folder = os.path.dirname(common_path)
+ os.makedirs(cache_folder, exist_ok=True)
+ with open(common_path, "w") as f:
+ json.dump(common_file, f, separators=(",", ":"))
return uuid
@@ -343,20 +442,29 @@ def get_unique_identifier():
class RestrictedUnpickler(pickle.Unpickler):
- def __init__(self, *args, **kwargs):
+ generic_properties_module: Optional[object]
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
self.options_module = importlib.import_module("Options")
self.net_utils_module = importlib.import_module("NetUtils")
- self.generic_properties_module = importlib.import_module("worlds.generic")
+ self.generic_properties_module = None
- def find_class(self, module, name):
+ def find_class(self, module: str, name: str) -> type:
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
+ # used by OptionCounter
+ # necessary because the actual Options class instances are pickled when transfered to WebHost generation pool
+ if module == "collections" and name == "Counter":
+ return collections.Counter
# used by MultiServer -> savegame/multidata
- if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
+ if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
+ "SlotType", "NetworkSlot", "HintStatus"}:
return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate
- if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
+ if module == "worlds.generic" and name == "PlandoItem":
+ if not self.generic_properties_module:
+ self.generic_properties_module = importlib.import_module("worlds.generic")
return getattr(self.generic_properties_module, name)
# pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
if module.lower().endswith("options"):
@@ -365,17 +473,30 @@ def find_class(self, module, name):
else:
mod = importlib.import_module(module)
obj = getattr(mod, name)
- if issubclass(obj, self.options_module.Option):
+ if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
+ self.options_module.PlandoItem, self.options_module.PlandoText)):
return obj
# Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
-def restricted_loads(s):
+def restricted_loads(s: bytes) -> Any:
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()
+def restricted_dumps(obj: Any) -> bytes:
+ """Helper function analogous to pickle.dumps()."""
+ s = pickle.dumps(obj)
+ # Assert that the string can be successfully loaded by restricted_loads
+ try:
+ restricted_loads(s)
+ except pickle.UnpicklingError as e:
+ raise pickle.PicklingError(e) from e
+
+ return s
+
+
class ByValue:
"""
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
@@ -389,6 +510,15 @@ class KeyedDefaultDict(collections.defaultdict):
"""defaultdict variant that uses the missing key as argument to default_factory"""
default_factory: typing.Callable[[typing.Any], typing.Any]
+ def __init__(self,
+ default_factory: typing.Callable[[Any], Any] = None,
+ seq: typing.Union[typing.Mapping, typing.Iterable, None] = None,
+ **kwargs):
+ if seq is not None:
+ super().__init__(default_factory, seq, **kwargs)
+ else:
+ super().__init__(default_factory, **kwargs)
+
def __missing__(self, key):
self[key] = value = self.default_factory(key)
return value
@@ -405,9 +535,9 @@ def get_text_after(text: str, start: str) -> str:
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
-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):
+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",
+ add_timestamp: bool = False, exception_logger: typing.Optional[str] = None):
import datetime
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = user_path("logs")
@@ -425,11 +555,27 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
write_mode,
encoding="utf-8-sig")
file_handler.setFormatter(logging.Formatter(log_format))
+
+ class Filter(logging.Filter):
+ def __init__(self, filter_name: str, condition: typing.Callable[[logging.LogRecord], bool]) -> None:
+ super().__init__(filter_name)
+ self.condition = condition
+
+ def filter(self, record: logging.LogRecord) -> bool:
+ return self.condition(record)
+
+ file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
+ file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.getMessage()))
root_logger.addHandler(file_handler)
if sys.stdout:
- root_logger.addHandler(
- logging.StreamHandler(sys.stdout)
- )
+ formatter = logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
+ stream_handler = logging.StreamHandler(sys.stdout)
+ stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
+ if add_timestamp:
+ stream_handler.setFormatter(formatter)
+ root_logger.addHandler(stream_handler)
+ if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
@@ -440,7 +586,8 @@ def handle_exception(exc_type, exc_value, exc_traceback):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.getLogger(exception_logger).exception("Uncaught exception",
- exc_info=(exc_type, exc_value, exc_traceback))
+ exc_info=(exc_type, exc_value, exc_traceback),
+ extra={"NoStream": exception_logger is None})
return orig_hook(exc_type, exc_value, exc_traceback)
handle_exception._wrapped = True
@@ -463,12 +610,13 @@ def _cleanup():
import platform
logging.info(
f"Archipelago ({__version__}) logging initialized"
- f" on {platform.platform()}"
+ f" on {platform.platform()} process {os.getpid()}"
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
+ f"{' (frozen)' if is_frozen() else ''}"
)
-def stream_input(stream, queue):
+def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"):
def queuer():
while 1:
try:
@@ -478,6 +626,8 @@ def queuer():
else:
if text:
queue.put_nowait(text)
+ else:
+ sleep(0.01) # non-blocking stream
from threading import Thread
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
@@ -496,7 +646,7 @@ class VersionException(Exception):
pass
-def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
+def chaining_prefix(index: int, labels: typing.Sequence[str]) -> str:
text = ""
max_label = len(labels) - 1
while index > max_label:
@@ -519,32 +669,104 @@ def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
-def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
+def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit: typing.Optional[int] = None) \
-> typing.List[typing.Tuple[str, int]]:
import jellyfish
def get_fuzzy_ratio(word1: str, word2: str) -> float:
+ if word1 == word2:
+ return 1.01
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
- limit: int = limit if limit else len(wordlist)
+ limit = limit if limit else len(word_list)
return list(
map(
lambda container: (container[0], int(container[1]*100)), # convert up to limit to int %
sorted(
- map(lambda candidate:
- (candidate, get_fuzzy_ratio(input_word, candidate)),
- wordlist),
+ map(lambda candidate: (candidate, get_fuzzy_ratio(input_word, candidate)), word_list),
key=lambda element: element[1],
- reverse=True)[0:limit]
+ reverse=True
+ )[0:limit]
)
)
-def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \
+def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
+ picks = get_fuzzy_results(input_text, possible_answers, limit=2)
+ if len(picks) > 1:
+ dif = picks[0][1] - picks[1][1]
+ if picks[0][1] == 101:
+ return picks[0][0], True, "Perfect Match"
+ elif picks[0][1] == 100:
+ return picks[0][0], True, "Case Insensitive Perfect Match"
+ elif picks[0][1] < 75:
+ return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
+ f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
+ elif dif > 5:
+ return picks[0][0], True, "Close Match"
+ else:
+ return picks[0][0], False, f"Too many close matches for '{input_text}', " \
+ f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
+ else:
+ if picks[0][1] > 90:
+ return picks[0][0], True, "Only Option Match"
+ else:
+ return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
+ f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"
+
+
+def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
+ """
+ Parses the response text from `get_intended_text` to find the suggested input and autocomplete the command in
+ arguments with it.
+
+ :param text: The response text from `get_intended_text`.
+ :param command: The command to which the input text should be added. Must contain the prefix used by the command
+ (`!` or `/`).
+ :return: The command with the suggested input text appended, or None if no suggestion was found.
+ """
+ if "did you mean " in text:
+ for question in ("Didn't find something that closely matches",
+ "Too many close matches"):
+ if text.startswith(question):
+ name = get_text_between(text, "did you mean '",
+ "'? (")
+ return f"{command} {name}"
+ elif text.startswith("Missing: "):
+ return text.replace("Missing: ", "!hint_location ")
+ return None
+
+
+def is_kivy_running() -> bool:
+ if "kivy" in sys.modules:
+ from kivy.app import App
+ return App.get_running_app() is not None
+ return False
+
+
+def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
+ if is_kivy_running():
+ raise RuntimeError("kivy should not be running in multiprocess")
+ res.put(open_filename(*args))
+
+
+def _mp_save_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
+ if is_kivy_running():
+ raise RuntimeError("kivy should not be running in multiprocess")
+ res.put(save_filename(*args))
+
+def _run_for_stdout(*args: str):
+ env = os.environ
+ if "LD_LIBRARY_PATH" in env:
+ env = env.copy()
+ del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
+ return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
+
+
+def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]:
- def run(*args: str):
- return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
+ logging.info(f"Opening file input dialog for {title}.")
if is_linux:
# prefer native dialog
@@ -552,12 +774,12 @@ def run(*args: str):
kdialog = which("kdialog")
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
- return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
+ return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
- selection = (f'--filename="{suggest}',) if suggest else ()
- return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
+ selection = (f"--filename={suggest}",) if suggest else ()
+ return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
try:
@@ -568,27 +790,86 @@ def run(*args: str):
f'This attempt was made because open_filename was used for "{title}".')
raise e
else:
- root = tkinter.Tk()
+ if is_macos and is_kivy_running():
+ # on macOS, mixing kivy and tk does not work, so spawn a new process
+ # FIXME: performance of this is pretty bad, and we should (also) look into alternatives
+ from multiprocessing import Process, Queue
+ res: "Queue[typing.Optional[str]]" = Queue()
+ Process(target=_mp_open_filename, args=(res, title, filetypes, suggest)).start()
+ return res.get()
+ try:
+ root = tkinter.Tk()
+ except tkinter.TclError:
+ return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
initialfile=suggest or None)
-def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
+def save_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
+ -> typing.Optional[str]:
+ logging.info(f"Opening file save dialog for {title}.")
+
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux:
# prefer native dialog
from shutil import which
- kdialog = None#which("kdialog")
+ kdialog = which("kdialog")
+ if kdialog:
+ k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
+ return run(kdialog, f"--title={title}", "--getsavefilename", suggest or ".", k_filters)
+ zenity = which("zenity")
+ if zenity:
+ z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
+ selection = (f"--filename={suggest}",) if suggest else ()
+ return run(zenity, f"--title={title}", "--file-selection", "--save", *z_filters, *selection)
+
+ # fall back to tk
+ try:
+ import tkinter
+ import tkinter.filedialog
+ except Exception as e:
+ logging.error('Could not load tkinter, which is likely not installed. '
+ f'This attempt was made because save_filename was used for "{title}".')
+ raise e
+ else:
+ if is_macos and is_kivy_running():
+ # on macOS, mixing kivy and tk does not work, so spawn a new process
+ # FIXME: performance of this is pretty bad, and we should (also) look into alternatives
+ from multiprocessing import Process, Queue
+ res: "Queue[typing.Optional[str]]" = Queue()
+ Process(target=_mp_save_filename, args=(res, title, filetypes, suggest)).start()
+ return res.get()
+ try:
+ root = tkinter.Tk()
+ except tkinter.TclError:
+ return None # GUI not available. None is the same as a user clicking "cancel"
+ root.withdraw()
+ return tkinter.filedialog.asksaveasfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes),
+ initialfile=suggest or None)
+
+
+def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None:
+ if is_kivy_running():
+ raise RuntimeError("kivy should not be running in multiprocess")
+ res.put(open_directory(*args))
+
+
+def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
+ if is_linux:
+ # prefer native dialog
+ from shutil import which
+ kdialog = which("kdialog")
if kdialog:
- return run(kdialog, f"--title={title}", "--getexistingdirectory", suggest or ".")
- zenity = None#which("zenity")
+ return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory",
+ os.path.abspath(suggest) if suggest else ".")
+ zenity = which("zenity")
if zenity:
z_filters = ("--directory",)
- selection = (f'--filename="{suggest}',) if suggest else ()
- return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
+ selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
+ return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
try:
@@ -596,24 +877,25 @@ def run(*args: str):
import tkinter.filedialog
except Exception as e:
logging.error('Could not load tkinter, which is likely not installed. '
- f'This attempt was made because open_filename was used for "{title}".')
+ f'This attempt was made because open_directory was used for "{title}".')
raise e
else:
- root = tkinter.Tk()
+ if is_macos and is_kivy_running():
+ # on macOS, mixing kivy and tk does not work, so spawn a new process
+ # FIXME: performance of this is pretty bad, and we should (also) look into alternatives
+ from multiprocessing import Process, Queue
+ res: "Queue[typing.Optional[str]]" = Queue()
+ Process(target=_mp_open_directory, args=(res, title, suggest)).start()
+ return res.get()
+ try:
+ root = tkinter.Tk()
+ except tkinter.TclError:
+ return None # GUI not available. None is the same as a user clicking "cancel"
root.withdraw()
return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None)
def messagebox(title: str, text: str, error: bool = False) -> None:
- def run(*args: str):
- return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
-
- def is_kivy_running():
- if "kivy" in sys.modules:
- from kivy.app import App
- return App.get_running_app() is not None
- return False
-
if is_kivy_running():
from kvui import MessageBox
MessageBox(title, text, error).open()
@@ -624,10 +906,15 @@ def is_kivy_running():
from shutil import which
kdialog = which("kdialog")
if kdialog:
- return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
+ return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
zenity = which("zenity")
if zenity:
- return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
+ return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
+
+ elif is_windows:
+ import ctypes
+ style = 0x10 if error else 0x0
+ return ctypes.windll.user32.MessageBoxW(0, text, title, style)
# fall back to tk
try:
@@ -644,7 +931,7 @@ def is_kivy_running():
root.update()
-def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
+def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: Union[str, Dict[str, Any]]) -> str:
if (not isinstance(element, str)):
@@ -674,7 +961,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget"
"""
- # https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
+ # https://docs.python.org/3.11/library/asyncio-task.html#asyncio.create_task
# Python docs:
# ```
# Important: Save a reference to the result of [asyncio.create_task],
@@ -687,22 +974,39 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
task.add_done_callback(_faf_tasks.discard)
-def deprecate(message: str):
+def deprecate(message: str, add_stacklevels: int = 0):
if __debug__:
raise Exception(message)
- import warnings
- warnings.warn(message)
+ warnings.warn(message, stacklevel=2 + add_stacklevels)
+
+
+class DeprecateDict(dict):
+ log_message: str
+ should_error: bool
+
+ def __init__(self, message: str, error: bool = False) -> None:
+ self.log_message = message
+ self.should_error = error
+ super().__init__()
+
+ def __getitem__(self, item: Any) -> Any:
+ if self.should_error:
+ deprecate(self.log_message, add_stacklevels=1)
+ elif __debug__:
+ warnings.warn(self.log_message, stacklevel=2)
+ return super().__getitem__(item)
+
def _extend_freeze_support() -> None:
- """Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
- # upstream issue: https://github.com/python/cpython/issues/76327
+ """Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first."""
+ # original upstream issue: https://github.com/python/cpython/issues/76327
# code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26
import multiprocessing
import multiprocessing.spawn
def _freeze_support() -> None:
"""Minimal freeze_support. Only apply this if frozen."""
- from subprocess import _args_from_interpreter_flags
+ from subprocess import _args_from_interpreter_flags # noqa
# Prevent `spawn` from trying to read `__main__` in from the main script
multiprocessing.process.ORIGINAL_DIR = None
@@ -710,8 +1014,7 @@ def _freeze_support() -> None:
# Handle the first process that MP will create
if (
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
- 'from multiprocessing.semaphore_tracker import main', # Py<3.8
- 'from multiprocessing.resource_tracker import main', # Py>=3.8
+ 'from multiprocessing.resource_tracker import main',
'from multiprocessing.forkserver import main'
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
):
@@ -730,12 +1033,192 @@ def _freeze_support() -> None:
multiprocessing.spawn.spawn_main(**kwargs)
sys.exit()
- if not is_windows and is_frozen():
- multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support
+ def _noop() -> None:
+ pass
+
+ multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
def freeze_support() -> None:
- """This behaves like multiprocessing.freeze_support but also works on Non-Windows."""
+ """This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load."""
import multiprocessing
- _extend_freeze_support()
+
+ deprecate("Use multiprocessing.freeze_support() instead")
multiprocessing.freeze_support()
+
+
+_extend_freeze_support()
+
+
+def visualize_regions(root_region: Region, file_name: str, *,
+ show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
+ linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
+ """Visualize the layout of a world as a PlantUML diagram.
+
+ :param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
+ :param file_name: The name of the destination .puml file.
+ :param show_entrance_names: (default False) If enabled, the name of the entrance will be shown near each connection.
+ :param show_locations: (default True) If enabled, the locations will be listed inside each region.
+ Priority locations will be shown in bold.
+ Excluded locations will be stricken out.
+ Locations without ID will be shown in italics.
+ Locked locations will be shown with a padlock icon.
+ For filled locations, the item name will be shown after the location name.
+ Progression items will be shown in bold.
+ Items without ID will be shown in italics.
+ :param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
+ :param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
+ :param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
+
+ Example usage in World code:
+ from Utils import visualize_regions
+ state = self.multiworld.get_all_state(False)
+ state.update_reachable_regions(self.player)
+ visualize_regions(self.get_region("Menu"), "my_world.puml", show_entrance_names=True,
+ regions_to_highlight=state.reachable_regions[self.player])
+
+ Example usage in Main code:
+ from Utils import visualize_regions
+ for player in multiworld.player_ids:
+ visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
+ """
+ if regions_to_highlight is None:
+ regions_to_highlight = set()
+ assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
+ from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
+ from collections import deque
+ import re
+
+ uml: typing.List[str] = list()
+ seen: typing.Set[Region] = set()
+ regions: typing.Deque[Region] = deque((root_region,))
+ multiworld: MultiWorld = root_region.multiworld
+
+ def fmt(obj: Union[Entrance, Item, Location, Region]) -> str:
+ name = obj.name
+ if isinstance(obj, Item):
+ name = multiworld.get_name_string_for_object(obj)
+ if obj.advancement:
+ name = f"**{name}**"
+ if obj.code is None:
+ name = f"//{name}//"
+ if isinstance(obj, Location):
+ if obj.progress_type == LocationProgressType.PRIORITY:
+ name = f"**{name}**"
+ elif obj.progress_type == LocationProgressType.EXCLUDED:
+ name = f"--{name}--"
+ if obj.address is None:
+ name = f"//{name}//"
+ return re.sub("[\".:]", "", name)
+
+ def visualize_exits(region: Region) -> None:
+ for exit_ in region.exits:
+ if exit_.connected_region:
+ if show_entrance_names:
+ uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"")
+ else:
+ try:
+ uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"")
+ uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"")
+ except ValueError:
+ uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"")
+ else:
+ uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"")
+ uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"")
+
+ def visualize_locations(region: Region) -> None:
+ any_lock = any(location.locked for location in region.locations)
+ for location in region.locations:
+ lock = "<&lock-locked> " if location.locked else "<&lock-unlocked,color=transparent> " if any_lock else ""
+ if location.item:
+ uml.append(f"\"{fmt(region)}\" : {{method}} {lock}{fmt(location)}: {fmt(location.item)}")
+ else:
+ uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
+
+ def visualize_region(region: Region) -> None:
+ uml.append(f"class \"{fmt(region)}\" {'#00FF00' if region in regions_to_highlight else ''}")
+ if show_locations:
+ visualize_locations(region)
+ visualize_exits(region)
+
+ def visualize_other_regions() -> None:
+ if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]:
+ uml.append("package \"other regions\" <> {")
+ for region in other_regions:
+ uml.append(f"class \"{fmt(region)}\"")
+ uml.append("}")
+
+ uml.append("@startuml")
+ uml.append("hide circle")
+ uml.append("hide empty members")
+ if linetype_ortho:
+ uml.append("skinparam linetype ortho")
+ while regions:
+ if (current_region := regions.popleft()) not in seen:
+ seen.add(current_region)
+ visualize_region(current_region)
+ regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region)
+ if show_other_regions:
+ visualize_other_regions()
+ uml.append("@enduml")
+
+ with open(file_name, "wt", encoding="utf-8") as f:
+ f.write("\n".join(uml))
+
+
+class RepeatableChain:
+ def __init__(self, iterable: typing.Iterable):
+ self.iterable = iterable
+
+ def __iter__(self):
+ return itertools.chain.from_iterable(self.iterable)
+
+ def __bool__(self):
+ return any(sub_iterable for sub_iterable in self.iterable)
+
+ def __len__(self):
+ return sum(len(iterable) for iterable in self.iterable)
+
+
+def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]]:
+ """ `str` is `Iterable`, but that's not what we want """
+ if isinstance(obj, str):
+ return False
+ return isinstance(obj, typing.Iterable)
+
+
+class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
+ """
+ ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
+ NOTE: use this with caution because killed threads will not properly clean up.
+ """
+
+ def _adjust_thread_count(self):
+ # see upstream ThreadPoolExecutor for details
+ import threading
+ import weakref
+ from concurrent.futures.thread import _worker
+
+ if self._idle_semaphore.acquire(timeout=0):
+ return
+
+ def weakref_cb(_, q=self._work_queue):
+ q.put(None)
+
+ num_threads = len(self._threads)
+ if num_threads < self._max_workers:
+ thread_name = f"{self._thread_name_prefix or self}_{num_threads}"
+ t = threading.Thread(
+ name=thread_name,
+ target=_worker,
+ args=(
+ weakref.ref(self, weakref_cb),
+ self._work_queue,
+ self._initializer,
+ self._initargs,
+ ),
+ daemon=True,
+ )
+ t.start()
+ self._threads.add(t)
+ # NOTE: don't add to _threads_queues so we don't block on shutdown
diff --git a/WargrooveClient.py b/WargrooveClient.py
deleted file mode 100644
index 16bfeb15ab6b..000000000000
--- a/WargrooveClient.py
+++ /dev/null
@@ -1,445 +0,0 @@
-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()
diff --git a/WebHost.py b/WebHost.py
index 45d017cf1f67..db465be61beb 100644
--- a/WebHost.py
+++ b/WebHost.py
@@ -1,3 +1,4 @@
+import argparse
import os
import multiprocessing
import logging
@@ -11,58 +12,66 @@
# in case app gets imported by something like gunicorn
import Utils
import settings
+from Utils import get_file_safe_name
-Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
-
-from WebHostLib import register, app as raw_app
-from waitress import serve
-
-from WebHostLib.models import db
-from WebHostLib.autolauncher import autohost, autogen
-from WebHostLib.lttpsprites import update_sprites_lttp
-from WebHostLib.options import create as create_options_files
+if typing.TYPE_CHECKING:
+ from flask import Flask
+Utils.local_path.cached_path = os.path.dirname(__file__)
settings.no_gui = True
configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml'))
-def get_app():
- register()
+def get_app() -> "Flask":
+ from WebHostLib import register, cache, app as raw_app
+ from WebHostLib.models import db
+
app = raw_app
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}")
+ # inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
+ parser = argparse.ArgumentParser(allow_abbrev=False)
+ parser.add_argument('--config_override', default=None,
+ help="Path to yaml config file that overrules config.yaml.")
+ args = parser.parse_known_args()[0]
+ if args.config_override:
+ import yaml
+ app.config.from_file(os.path.abspath(args.config_override), yaml.safe_load)
+ logging.info(f"Updated config from {args.config_override}")
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']}")
+ register()
+ cache.init_app(app)
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)
return app
-def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
- import json
+def copy_tutorials_files_to_static() -> None:
import shutil
import zipfile
+ from werkzeug.utils import secure_filename
zfile: zipfile.ZipInfo
from worlds.AutoWorld import AutoWorldRegister
worlds = {}
- data = []
for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
worlds[game] = world
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
+ shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items():
# copy files from world's docs folder to the generated folder
- target_path = os.path.join(base_target_path, game)
+ target_path = os.path.join(base_target_path, secure_filename(game))
os.makedirs(target_path, exist_ok=True)
if world.zip_path:
@@ -75,59 +84,40 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
for zfile in zf.infolist():
if not zfile.is_dir() and "/docs/" in zfile.filename:
zfile.filename = os.path.basename(zfile.filename)
- zf.extract(zfile, target_path)
+ with open(os.path.join(target_path, secure_filename(zfile.filename)), "wb") as f:
+ f.write(zf.read(zfile))
else:
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
files = os.listdir(source_path)
for file in files:
- shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
-
- # build a json tutorial dict per game
- game_data = {'gameTitle': game, 'tutorials': []}
- for tutorial in world.web.tutorials:
- # build dict for the json file
- current_tutorial = {
- 'name': tutorial.tutorial_name,
- 'description': tutorial.description,
- 'files': [{
- 'language': tutorial.language,
- 'filename': game + '/' + tutorial.file_name,
- 'link': f'{game}/{tutorial.link}',
- 'authors': tutorial.authors
- }]
- }
-
- # check if the name of the current guide exists already
- for guide in game_data['tutorials']:
- if guide and tutorial.tutorial_name == guide['name']:
- guide['files'].append(current_tutorial['files'][0])
- break
- else:
- game_data['tutorials'].append(current_tutorial)
-
- data.append(game_data)
- with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
- generic_data = {}
- for games in data:
- if 'Archipelago' in games['gameTitle']:
- generic_data = data.pop(data.index(games))
- sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
- json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
- return sorted_data
+ shutil.copyfile(Utils.local_path(source_path, file),
+ Utils.local_path(target_path, secure_filename(file)))
if __name__ == "__main__":
multiprocessing.freeze_support()
multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
+
+ from WebHostLib.autolauncher import autohost, autogen, stop
+ from WebHostLib.options import create as create_options_files
+
try:
+ from WebHostLib.lttpsprites import update_sprites_lttp
update_sprites_lttp()
except Exception as e:
logging.exception(e)
logging.warning("Could not update LttP sprites.")
app = get_app()
+ from worlds import AutoWorldRegister
+ # Update to only valid WebHost worlds
+ invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items()
+ if not hasattr(world.web, "tutorials")}
+ if invalid_worlds:
+ logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}")
+ AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds}
create_options_files()
- create_ordered_tutorials_file()
+ copy_tutorials_files_to_static()
if app.config["SELFLAUNCH"]:
autohost(app.config)
if app.config["SELFGEN"]:
@@ -136,4 +126,13 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
if app.config["DEBUG"]:
app.run(debug=True, port=app.config["PORT"])
else:
+ from waitress import serve
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
+ else:
+ from time import sleep
+ try:
+ while True:
+ sleep(1) # wait for process to be killed
+ except (SystemExit, KeyboardInterrupt):
+ pass
+ stop() # stop worker threads
diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py
index a59e3aa553f3..e4c2ab83c76c 100644
--- a/WebHostLib/__init__.py
+++ b/WebHostLib/__init__.py
@@ -1,6 +1,7 @@
import base64
import os
import socket
+import typing
import uuid
from flask import Flask
@@ -9,7 +10,7 @@
from pony.flask import Pony
from werkzeug.routing import BaseConverter
-from Utils import title_sorted
+from Utils import title_sorted, get_file_safe_name
UPLOAD_FOLDER = os.path.relpath('uploads')
LOGS_FOLDER = os.path.relpath('logs')
@@ -20,9 +21,11 @@
app.jinja_env.filters['any'] = any
app.jinja_env.filters['all'] = all
+app.jinja_env.filters['get_file_safe_name'] = get_file_safe_name
app.config["SELFHOST"] = True # application process is in charge of running the websites
app.config["GENERATORS"] = 8 # maximum concurrent world gens
+app.config["HOSTERS"] = 8 # maximum concurrent room hosters
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
@@ -37,6 +40,8 @@
app.config["JOB_THRESHOLD"] = 1
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
+# memory limit for generator processes in bytes
+app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent
@@ -49,40 +54,52 @@
'create_db': True
}
app.config["MAX_ROLL"] = 20
-app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
-app.config["JSON_AS_ASCII"] = False
+app.config["CACHE_TYPE"] = "SimpleCache"
app.config["HOST_ADDRESS"] = ""
+app.config["ASSET_RIGHTS"] = False
-cache = Cache(app)
+cache = Cache()
Compress(app)
+def to_python(value: str) -> uuid.UUID:
+ return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
+
+
+def to_url(value: uuid.UUID) -> str:
+ return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
+
+
class B64UUIDConverter(BaseConverter):
- def to_python(self, value):
- return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
+ def to_python(self, value: str) -> uuid.UUID:
+ return to_python(value)
- def to_url(self, value):
- return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
+ def to_url(self, value: typing.Any) -> str:
+ assert isinstance(value, uuid.UUID)
+ return to_url(value)
# short UUID
app.url_map.converters["suuid"] = B64UUIDConverter
-app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
+app.jinja_env.filters["suuid"] = to_url
app.jinja_env.filters["title_sorted"] = title_sorted
-def register():
+def register() -> None:
"""Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem."""
+ import importlib
+
+ from werkzeug.utils import find_modules
# has automatic patch integration
- import worlds.AutoWorld
import worlds.Files
- app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
- game_name in worlds.Files.AutoPatchRegister.patch_types
+ app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
from WebHostLib.customserver import run_server_process
- # to trigger app routing picking up on it
- from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
+ for module in find_modules("WebHostLib", include_packages=True):
+ importlib.import_module(module)
+
+ from . import api
app.register_blueprint(api.api_endpoints)
diff --git a/WebHostLib/api/__init__.py b/WebHostLib/api/__init__.py
index 102c3a49f6aa..54eb5c1de151 100644
--- a/WebHostLib/api/__init__.py
+++ b/WebHostLib/api/__init__.py
@@ -1,59 +1,15 @@
"""API endpoints package."""
from typing import List, Tuple
-from uuid import UUID
-from flask import Blueprint, abort
+from flask import Blueprint
-from .. import cache
-from ..models import Room, Seed
+from ..models import Seed, Slot
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
-# unsorted/misc endpoints
-
def get_players(seed: Seed) -> List[Tuple[str, str]]:
- return [(slot.player_name, slot.game) for slot in seed.slots]
-
-
-@api_endpoints.route('/room_status/')
-def room_info(room: UUID):
- room = Room.get(id=room)
- if room is None:
- return abort(404)
- return {
- "tracker": room.tracker,
- "players": get_players(room.seed),
- "last_port": room.last_port,
- "last_activity": room.last_activity,
- "timeout": room.timeout
- }
-
-
-@api_endpoints.route('/datapackage')
-@cache.cached()
-def get_datapackage():
- from worlds import network_data_package
- return network_data_package
-
-
-@api_endpoints.route('/datapackage_version')
-@cache.cached()
-def get_datapackage_versions():
- from worlds import AutoWorldRegister
-
- version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
- return version_package
-
-
-@api_endpoints.route('/datapackage_checksum')
-@cache.cached()
-def get_datapackage_checksums():
- from worlds import network_data_package
- version_package = {
- game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
- }
- return version_package
-
+ return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
-from . import generate, user # trigger registration
+# trigger endpoint registration
+from . import datapackage, generate, room, tracker, user
diff --git a/WebHostLib/api/datapackage.py b/WebHostLib/api/datapackage.py
new file mode 100644
index 000000000000..3fb472d95dfd
--- /dev/null
+++ b/WebHostLib/api/datapackage.py
@@ -0,0 +1,32 @@
+from flask import abort
+
+from Utils import restricted_loads
+from WebHostLib import cache
+from WebHostLib.models import GameDataPackage
+from . import api_endpoints
+
+
+@api_endpoints.route('/datapackage')
+@cache.cached()
+def get_datapackage():
+ from worlds import network_data_package
+ return network_data_package
+
+
+@api_endpoints.route('/datapackage/')
+@cache.memoize(timeout=3600)
+def get_datapackage_by_checksum(checksum: str):
+ package = GameDataPackage.get(checksum=checksum)
+ if package:
+ return restricted_loads(package.data)
+ return abort(404)
+
+
+@api_endpoints.route('/datapackage_checksum')
+@cache.cached()
+def get_datapackage_checksums():
+ from worlds import network_data_package
+ version_package = {
+ game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
+ }
+ return version_package
diff --git a/WebHostLib/api/generate.py b/WebHostLib/api/generate.py
index 61e9164e2652..7bcbdbcf19d7 100644
--- a/WebHostLib/api/generate.py
+++ b/WebHostLib/api/generate.py
@@ -1,11 +1,11 @@
import json
-import pickle
from uuid import UUID
from flask import request, session, url_for
from markupsafe import Markup
from pony.orm import commit
+from Utils import restricted_dumps
from WebHostLib import app
from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta
@@ -20,8 +20,8 @@ def generate_api():
race = False
meta_options_source = {}
if 'file' in request.files:
- file = request.files['file']
- options = get_yaml_data(file)
+ files = request.files.getlist('file')
+ options = get_yaml_data(files)
if isinstance(options, Markup):
return {"text": options.striptags()}, 400
if isinstance(options, str):
@@ -56,7 +56,7 @@ def generate_api():
"detail": results}, 400
else:
gen = Generation(
- options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
+ options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta), state=STATE_QUEUED,
owner=session["_id"])
diff --git a/WebHostLib/api/room.py b/WebHostLib/api/room.py
new file mode 100644
index 000000000000..78623bbe3eb3
--- /dev/null
+++ b/WebHostLib/api/room.py
@@ -0,0 +1,43 @@
+from typing import Any, Dict
+from uuid import UUID
+
+from flask import abort, url_for
+
+from WebHostLib import to_url
+import worlds.Files
+from . import api_endpoints, get_players
+from ..models import Room
+
+
+@api_endpoints.route('/room_status/')
+def room_info(room_id: UUID) -> Dict[str, Any]:
+ room = Room.get(id=room_id)
+ if room is None:
+ return abort(404)
+
+ def supports_apdeltapatch(game: str) -> bool:
+ return game in worlds.Files.AutoPatchRegister.patch_types
+
+ downloads = []
+ for slot in sorted(room.seed.slots):
+ if slot.data and not supports_apdeltapatch(slot.game):
+ slot_download = {
+ "slot": slot.player_id,
+ "download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
+ }
+ downloads.append(slot_download)
+ elif slot.data:
+ slot_download = {
+ "slot": slot.player_id,
+ "download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
+ }
+ downloads.append(slot_download)
+
+ return {
+ "tracker": to_url(room.tracker),
+ "players": get_players(room.seed),
+ "last_port": room.last_port,
+ "last_activity": room.last_activity,
+ "timeout": room.timeout,
+ "downloads": downloads,
+ }
diff --git a/WebHostLib/api/tracker.py b/WebHostLib/api/tracker.py
new file mode 100644
index 000000000000..1e6e02bee778
--- /dev/null
+++ b/WebHostLib/api/tracker.py
@@ -0,0 +1,258 @@
+from datetime import datetime, timezone
+from typing import Any, TypedDict
+from uuid import UUID
+
+from flask import abort
+
+from NetUtils import ClientStatus, Hint, NetworkItem, SlotType
+from WebHostLib import cache
+from WebHostLib.api import api_endpoints
+from WebHostLib.models import Room
+from WebHostLib.tracker import TrackerData
+
+
+class PlayerAlias(TypedDict):
+ team: int
+ player: int
+ alias: str | None
+
+
+class PlayerItemsReceived(TypedDict):
+ team: int
+ player: int
+ items: list[NetworkItem]
+
+
+class PlayerChecksDone(TypedDict):
+ team: int
+ player: int
+ locations: list[int]
+
+
+class TeamTotalChecks(TypedDict):
+ team: int
+ checks_done: int
+
+
+class PlayerHints(TypedDict):
+ team: int
+ player: int
+ hints: list[Hint]
+
+
+class PlayerTimer(TypedDict):
+ team: int
+ player: int
+ time: datetime | None
+
+
+class PlayerStatus(TypedDict):
+ team: int
+ player: int
+ status: ClientStatus
+
+
+class PlayerLocationsTotal(TypedDict):
+ team: int
+ player: int
+ total_locations: int
+
+
+class PlayerGame(TypedDict):
+ team: int
+ player: int
+ game: str
+
+
+@api_endpoints.route("/tracker/")
+@cache.memoize(timeout=60)
+def tracker_data(tracker: UUID) -> dict[str, Any]:
+ """
+ Outputs json data to /api/tracker/.
+
+ :param tracker: UUID of current session tracker.
+
+ :return: Tracking data for all players in the room. Typing and docstrings describe the format of each value.
+ """
+ room: Room | None = Room.get(tracker=tracker)
+ if not room:
+ abort(404)
+
+ tracker_data = TrackerData(room)
+
+ all_players: dict[int, list[int]] = tracker_data.get_all_players()
+
+ player_aliases: list[PlayerAlias] = []
+ """Slot aliases of all players."""
+ for team, players in all_players.items():
+ for player in players:
+ player_aliases.append(
+ {"team": team, "player": player, "alias": tracker_data.get_player_alias(team, player)})
+
+ player_items_received: list[PlayerItemsReceived] = []
+ """Items received by each player."""
+ for team, players in all_players.items():
+ for player in players:
+ player_items_received.append(
+ {"team": team, "player": player, "items": tracker_data.get_player_received_items(team, player)})
+
+ player_checks_done: list[PlayerChecksDone] = []
+ """ID of all locations checked by each player."""
+ for team, players in all_players.items():
+ for player in players:
+ player_checks_done.append(
+ {"team": team, "player": player,
+ "locations": sorted(tracker_data.get_player_checked_locations(team, player))})
+
+ total_checks_done: list[TeamTotalChecks] = [
+ {"team": team, "checks_done": checks_done}
+ for team, checks_done in tracker_data.get_team_locations_checked_count().items()
+ ]
+ """Total number of locations checked for the entire multiworld per team."""
+
+ hints: list[PlayerHints] = []
+ """Hints that all players have used or received."""
+ for team, players in tracker_data.get_all_slots().items():
+ for player in players:
+ player_hints = sorted(tracker_data.get_player_hints(team, player))
+ hints.append({"team": team, "player": player, "hints": player_hints})
+ slot_info = tracker_data.get_slot_info(player)
+ # this assumes groups are always after players
+ if slot_info.type != SlotType.group:
+ continue
+ for member in slot_info.group_members:
+ hints[member - 1]["hints"] += player_hints
+
+ activity_timers: list[PlayerTimer] = []
+ """Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made."""
+ for team, players in all_players.items():
+ for player in players:
+ activity_timers.append({"team": team, "player": player, "time": None})
+
+ for (team, player), timestamp in tracker_data._multisave.get("client_activity_timers", []):
+ for entry in activity_timers:
+ if entry["team"] == team and entry["player"] == player:
+ entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
+ break
+
+ connection_timers: list[PlayerTimer] = []
+ """Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made."""
+ for team, players in all_players.items():
+ for player in players:
+ connection_timers.append({"team": team, "player": player, "time": None})
+
+ for (team, player), timestamp in tracker_data._multisave.get("client_connection_timers", []):
+ # find the matching entry
+ for entry in connection_timers:
+ if entry["team"] == team and entry["player"] == player:
+ entry["time"] = datetime.fromtimestamp(timestamp, timezone.utc)
+ break
+
+ player_status: list[PlayerStatus] = []
+ """The current client status for each player."""
+ for team, players in all_players.items():
+ for player in players:
+ player_status.append(
+ {"team": team, "player": player, "status": tracker_data.get_player_client_status(team, player)})
+
+ return {
+ "aliases": player_aliases,
+ "player_items_received": player_items_received,
+ "player_checks_done": player_checks_done,
+ "total_checks_done": total_checks_done,
+ "hints": hints,
+ "activity_timers": activity_timers,
+ "connection_timers": connection_timers,
+ "player_status": player_status,
+ }
+
+
+class PlayerGroups(TypedDict):
+ slot: int
+ name: str
+ members: list[int]
+
+
+class PlayerSlotData(TypedDict):
+ player: int
+ slot_data: dict[str, Any]
+
+
+@api_endpoints.route("/static_tracker/")
+@cache.memoize(timeout=300)
+def static_tracker_data(tracker: UUID) -> dict[str, Any]:
+ """
+ Outputs json data to /api/static_tracker/.
+
+ :param tracker: UUID of current session tracker.
+
+ :return: Static tracking data for all players in the room. Typing and docstrings describe the format of each value.
+ """
+ room: Room | None = Room.get(tracker=tracker)
+ if not room:
+ abort(404)
+ tracker_data = TrackerData(room)
+
+ all_players: dict[int, list[int]] = tracker_data.get_all_players()
+
+ groups: list[PlayerGroups] = []
+ """The Slot ID of groups and the IDs of the group's members."""
+ for team, players in tracker_data.get_all_slots().items():
+ for player in players:
+ slot_info = tracker_data.get_slot_info(player)
+ if slot_info.type != SlotType.group or not slot_info.group_members:
+ continue
+ groups.append(
+ {
+ "slot": player,
+ "name": slot_info.name,
+ "members": list(slot_info.group_members),
+ })
+ break
+
+ player_locations_total: list[PlayerLocationsTotal] = []
+ for team, players in all_players.items():
+ for player in players:
+ player_locations_total.append(
+ {"team": team, "player": player, "total_locations": len(tracker_data.get_player_locations(player))})
+
+ player_game: list[PlayerGame] = []
+ """The played game per player slot."""
+ for team, players in all_players.items():
+ for player in players:
+ player_game.append({"team": team, "player": player, "game": tracker_data.get_player_game(player)})
+
+ return {
+ "groups": groups,
+ "datapackage": tracker_data._multidata["datapackage"],
+ "player_locations_total": player_locations_total,
+ "player_game": player_game,
+ }
+
+
+# It should be exceedingly rare that slot data is needed, so it's separated out.
+@api_endpoints.route("/slot_data_tracker/")
+@cache.memoize(timeout=300)
+def tracker_slot_data(tracker: UUID) -> list[PlayerSlotData]:
+ """
+ Outputs json data to /api/slot_data_tracker/.
+
+ :param tracker: UUID of current session tracker.
+
+ :return: Slot data for all players in the room. Typing completely arbitrary per game.
+ """
+ room: Room | None = Room.get(tracker=tracker)
+ if not room:
+ abort(404)
+ tracker_data = TrackerData(room)
+
+ all_players: dict[int, list[int]] = tracker_data.get_all_players()
+
+ slot_data: list[PlayerSlotData] = []
+ """Slot data for each player."""
+ for team, players in all_players.items():
+ for player in players:
+ slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(player)})
+ break
+
+ return slot_data
diff --git a/WebHostLib/api/user.py b/WebHostLib/api/user.py
index 116d3afa2288..59c8e5728332 100644
--- a/WebHostLib/api/user.py
+++ b/WebHostLib/api/user.py
@@ -1,6 +1,7 @@
from flask import session, jsonify
from pony.orm import select
+from WebHostLib import to_url
from WebHostLib.models import Room, Seed
from . import api_endpoints, get_players
@@ -10,13 +11,13 @@ def get_rooms():
response = []
for room in select(room for room in Room if room.owner == session["_id"]):
response.append({
- "room_id": room.id,
- "seed_id": room.seed.id,
+ "room_id": to_url(room.id),
+ "seed_id": to_url(room.seed.id),
"creation_time": room.creation_time,
"last_activity": room.last_activity,
"last_port": room.last_port,
"timeout": room.timeout,
- "tracker": room.tracker,
+ "tracker": to_url(room.tracker),
})
return jsonify(response)
@@ -26,8 +27,8 @@ def get_seeds():
response = []
for seed in select(seed for seed in Seed if seed.owner == session["_id"]):
response.append({
- "seed_id": seed.id,
+ "seed_id": to_url(seed.id),
"creation_time": seed.creation_time,
- "players": get_players(seed.slots),
+ "players": get_players(seed),
})
- return jsonify(response)
\ No newline at end of file
+ return jsonify(response)
diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py
index 0475a6329727..96ffbe9e9540 100644
--- a/WebHostLib/autolauncher.py
+++ b/WebHostLib/autolauncher.py
@@ -3,75 +3,26 @@
import json
import logging
import multiprocessing
-import os
-import sys
-import threading
-import time
import typing
from datetime import timedelta, datetime
+from threading import Event, Thread
+from typing import Any
+from uuid import UUID
-from pony.orm import db_session, select, commit
+from pony.orm import db_session, select, commit, PrimaryKey
from Utils import restricted_loads
+from .locker import Locker, AlreadyRunningException
+_stop_event = Event()
-class CommonLocker():
- """Uses a file lock to signal that something is already running"""
- lock_folder = "file_locks"
- def __init__(self, lockname: str, folder=None):
- if folder:
- self.lock_folder = folder
- os.makedirs(self.lock_folder, exist_ok=True)
- self.lockname = lockname
- self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
-
-
-class AlreadyRunningException(Exception):
- pass
-
-
-if sys.platform == 'win32':
- class Locker(CommonLocker):
- def __enter__(self):
- try:
- if os.path.exists(self.lockfile):
- os.unlink(self.lockfile)
- self.fp = os.open(
- self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
- except OSError as e:
- raise AlreadyRunningException() from e
-
- def __exit__(self, _type, value, tb):
- fp = getattr(self, "fp", None)
- if fp:
- os.close(self.fp)
- os.unlink(self.lockfile)
-else: # unix
- import fcntl
-
-
- class Locker(CommonLocker):
- def __enter__(self):
- try:
- self.fp = open(self.lockfile, "wb")
- fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
- except OSError as e:
- raise AlreadyRunningException() from e
-
- def __exit__(self, _type, value, tb):
- fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
- self.fp.close()
-
-
-def launch_room(room: Room, config: dict):
- # requires db_session!
- if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout):
- multiworld = multiworlds.get(room.id, None)
- if not multiworld:
- multiworld = MultiworldInstance(room, config)
-
- multiworld.start()
+def stop() -> None:
+ """Stops previously launched threads"""
+ global _stop_event
+ stop_event = _stop_event
+ _stop_event = Event() # new event for new threads
+ stop_event.set()
def handle_generation_success(seed_id):
@@ -85,16 +36,39 @@ def handle_generation_failure(result: BaseException):
logging.exception(e)
-def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
+def _mp_gen_game(
+ gen_options: dict,
+ meta: dict[str, Any] | None = None,
+ owner=None,
+ sid=None,
+ timeout: int|None = None,
+) -> PrimaryKey | None:
+ from setproctitle import setproctitle
+
+ setproctitle(f"Generator ({sid})")
+ try:
+ return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout)
+ finally:
+ setproctitle(f"Generator (idle)")
+
+
+def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None:
try:
meta = json.loads(generation.meta)
options = restricted_loads(generation.options)
logging.info(f"Generating {generation.id} for {len(options)} players")
- pool.apply_async(gen_game, (options,),
- {"meta": meta,
- "sid": generation.id,
- "owner": generation.owner},
- handle_generation_success, handle_generation_failure)
+ pool.apply_async(
+ _mp_gen_game,
+ (options,),
+ {
+ "meta": meta,
+ "sid": generation.id,
+ "owner": generation.owner,
+ "timeout": timeout,
+ },
+ handle_generation_success,
+ handle_generation_failure,
+ )
except Exception as e:
generation.state = STATE_ERROR
commit()
@@ -103,39 +77,79 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
generation.state = STATE_STARTED
-def init_db(pony_config: dict):
+def init_generator(config: dict[str, Any]) -> None:
+ from setproctitle import setproctitle
+
+ setproctitle("Generator (idle)")
+
+ try:
+ import resource
+ except ModuleNotFoundError:
+ pass # unix only module
+ else:
+ # set soft limit for memory to from config (default 4GiB)
+ soft_limit = config["GENERATOR_MEMORY_LIMIT"]
+ old_limit, hard_limit = resource.getrlimit(resource.RLIMIT_AS)
+ if soft_limit != old_limit:
+ resource.setrlimit(resource.RLIMIT_AS, (soft_limit, hard_limit))
+ logging.debug(f"Changed AS mem limit {old_limit} -> {soft_limit}")
+ del resource, soft_limit, hard_limit
+
+ pony_config = config["PONY"]
db.bind(**pony_config)
db.generate_mapping()
+def cleanup():
+ """delete unowned user-content"""
+ with db_session:
+ # >>> bool(uuid.UUID(int=0))
+ # True
+ rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True)
+ seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True)
+ slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True)
+ # Command gets deleted by ponyorm Cascade Delete, as Room is Required
+ if rooms or seeds or slots:
+ logging.info(f"{rooms} Rooms, {seeds} Seeds and {slots} Slots have been deleted.")
+
+
def autohost(config: dict):
def keep_running():
+ stop_event = _stop_event
try:
with Locker("autohost"):
- run_guardian()
- while 1:
- time.sleep(0.1)
+ cleanup()
+ hosters = []
+ for x in range(config["HOSTERS"]):
+ hoster = MultiworldInstance(config, x)
+ hosters.append(hoster)
+ hoster.start()
+
+ while not stop_event.wait(0.1):
with db_session:
rooms = select(
room for room in Room if
room.last_activity >= datetime.utcnow() - timedelta(days=3))
for room in rooms:
- launch_room(room, config)
+ # we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
+ if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
+ hosters[room.id.int % len(hosters)].start_room(room.id)
except AlreadyRunningException:
logging.info("Autohost reports as already running, not starting another.")
- import threading
- threading.Thread(target=keep_running, name="AP_Autohost").start()
+ Thread(target=keep_running, name="AP_Autohost").start()
def autogen(config: dict):
def keep_running():
+ stop_event = _stop_event
try:
with Locker("autogen"):
- with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
- initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
+ with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
+ initargs=(config,), maxtasksperchild=10) as generator_pool:
+ job_time = config["JOB_TIME"]
with db_session:
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
@@ -146,54 +160,58 @@ def keep_running():
if sid:
generation.delete()
else:
- launch_generator(generator_pool, generation)
+ launch_generator(generator_pool, generation, timeout=job_time)
commit()
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
- while 1:
- time.sleep(0.1)
+ while not stop_event.wait(0.1):
with db_session:
# for update locks the database row(s) during transaction, preventing writes from elsewhere
to_start = select(
generation for generation in Generation
if generation.state == STATE_QUEUED).for_update()
for generation in to_start:
- launch_generator(generator_pool, generation)
+ launch_generator(generator_pool, generation, timeout=job_time)
except AlreadyRunningException:
logging.info("Autogen reports as already running, not starting another.")
- import threading
- threading.Thread(target=keep_running, name="AP_Autogen").start()
-
-
-multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
+ Thread(target=keep_running, name="AP_Autogen").start()
class MultiworldInstance():
- def __init__(self, room: Room, config: dict):
- self.room_id = room.id
+ def __init__(self, config: dict, id: int):
+ self.room_ids = set()
self.process: typing.Optional[multiprocessing.Process] = None
- 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"]
+ self.rooms_to_start = multiprocessing.Queue()
+ self.rooms_shutting_down = multiprocessing.Queue()
+ self.name = f"MultiHoster{id}"
def start(self):
if self.process and self.process.is_alive():
return False
- 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(),
- self.cert, self.key, self.host),
- name="MultiHost")
+ args=(self.name, self.ponyconfig, get_static_server_data(),
+ self.cert, self.key, self.host,
+ self.rooms_to_start, self.rooms_shutting_down),
+ name=self.name)
process.start()
- # bind after start to prevent thread sync issues with guardian.
self.process = process
+ def start_room(self, room_id):
+ while not self.rooms_shutting_down.empty():
+ self.room_ids.remove(self.rooms_shutting_down.get(block=True, timeout=None))
+ if room_id in self.room_ids:
+ pass # should already be hosted currently.
+ else:
+ self.room_ids.add(room_id)
+ self.rooms_to_start.put(room_id)
+
def stop(self):
if self.process:
self.process.terminate()
@@ -207,40 +225,6 @@ def collect(self):
self.process = None
-guardian = None
-guardian_lock = threading.Lock()
-
-
-def run_guardian():
- global guardian
- global multiworlds
- with guardian_lock:
- if not guardian:
- try:
- import resource
- except ModuleNotFoundError:
- pass # unix only module
- else:
- # Each Server is another file handle, so request as many as we can from the system
- file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
- # set soft limit to hard limit
- resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
-
- def guard():
- while 1:
- time.sleep(1)
- done = []
- with guardian_lock:
- for key, instance in multiworlds.items():
- if instance.done():
- instance.collect()
- done.append(key)
- for key in done:
- del (multiworlds[key])
-
- guardian = threading.Thread(name="Guardian", target=guard)
-
-
-from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
+from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed, Slot
from .customserver import run_server_process, get_static_server_data
from .generate import gen_game
diff --git a/WebHostLib/check.py b/WebHostLib/check.py
index 0c1e090dbe47..b8e1fd875519 100644
--- a/WebHostLib/check.py
+++ b/WebHostLib/check.py
@@ -1,17 +1,13 @@
+import os
import zipfile
-from typing import *
+import base64
+from collections.abc import Set
from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup
from WebHostLib import app
-
-banned_zip_contents = (".sfc",)
-
-
-def allowed_file(filename):
- return filename.endswith(('.txt', ".yaml", ".zip"))
-
+from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file
from Generate import roll_settings, PlandoOptions
from Utils import parse_yamls
@@ -24,13 +20,21 @@ def check():
if 'file' not in request.files:
flash('No file part')
else:
- file = request.files['file']
- options = get_yaml_data(file)
+ files = request.files.getlist('file')
+ options = get_yaml_data(files)
if isinstance(options, str):
flash(options)
else:
results, _ = roll_options(options)
- return render_template("checkResult.html", results=results)
+ if len(options) > 1:
+ # offer combined file back
+ combined_yaml = "\n---\n".join(f"# original filename: {file_name}\n{file_content.decode('utf-8-sig')}"
+ for file_name, file_content in options.items())
+ combined_yaml = base64.b64encode(combined_yaml.encode("utf-8-sig")).decode()
+ else:
+ combined_yaml = ""
+ return render_template("checkResult.html",
+ results=results, combined_yaml=combined_yaml)
return render_template("check.html")
@@ -39,41 +43,53 @@ def mysterycheck():
return redirect(url_for("check"), 301)
-def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
+def get_yaml_data(files) -> dict[str, str] | str | Markup:
options = {}
- # if user does not select file, browser also
- # submit an empty part without filename
- if file.filename == '':
- return 'No selected file'
- elif file and allowed_file(file.filename):
- if file.filename.endswith(".zip"):
-
- 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 host a game ?')
+ for uploaded_file in files:
+ if banned_file(uploaded_file.filename):
+ return ("Uploaded data contained a rom file, which is likely to contain copyrighted material. "
+ "Your file was deleted.")
+ # If the user does not select file, the browser will still submit an empty string without a file name.
+ elif uploaded_file.filename == "":
+ return "No selected file."
+ elif uploaded_file.filename in options:
+ return f"Conflicting files named {uploaded_file.filename} submitted."
+ elif uploaded_file and allowed_options(uploaded_file.filename):
+ if uploaded_file.filename.endswith(".zip"):
+ if not zipfile.is_zipfile(uploaded_file):
+ return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened."
+
+ uploaded_file.seek(0) # offset from is_zipfile check
+ with zipfile.ZipFile(uploaded_file, "r") as zfile:
+ for file in zfile.infolist():
+ # Remove folder pathing from str (e.g. "__MACOSX/" folder paths from archives created by macOS).
+ base_filename = os.path.basename(file.filename)
+
+ if base_filename.endswith(".archipelago"):
+ return Markup("Error: Your .zip file contains an .archipelago file. "
+ 'Did you mean to host a game ?')
+ elif base_filename.endswith(".zip"):
+ return "Nested .zip files inside a .zip are not supported."
+ elif banned_file(base_filename):
+ return ("Uploaded data contained a rom file, which is likely to contain copyrighted "
+ "material. Your file was deleted.")
+ # Ignore dot-files.
+ elif not base_filename.startswith(".") and allowed_options(base_filename):
+ options[file.filename] = zfile.open(file, "r").read()
+ else:
+ options[uploaded_file.filename] = uploaded_file.read()
- for file in infolist:
- if file.filename.endswith(banned_zip_contents):
- return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
- "Your file was deleted."
- elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
- options[file.filename] = zfile.open(file, "r").read()
- else:
- options = {file.filename: file.read()}
if not options:
- return "Did not find a .yaml file to process."
+ return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}"
return options
-def roll_options(options: Dict[str, Union[dict, str]],
+def roll_options(options: dict[str, dict | str],
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
- Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
+ tuple[dict[str, str | bool], dict[str, dict]]:
plando_options = PlandoOptions.from_set(set(plando_options))
- results = {}
- rolled_results = {}
+ results: dict[str, str | bool] = {}
+ rolled_results: dict[str, dict] = {}
for filename, text in options.items():
try:
if type(text) is dict:
@@ -89,10 +105,14 @@ def roll_options(options: Dict[str, Union[dict, str]],
plando_options=plando_options)
else:
for i, yaml_data in enumerate(yaml_datas):
- rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
- plando_options=plando_options)
+ if yaml_data is not None:
+ rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
+ plando_options=plando_options)
except Exception as e:
- results[filename] = f"Failed to generate options in {filename}: {e}"
+ if e.__cause__:
+ results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
+ else:
+ results[filename] = f"Failed to generate options in {filename}: {e}"
else:
results[filename] = True
return results, rolled_results
diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py
index 8fbf692dec20..14ae291982bb 100644
--- a/WebHostLib/customserver.py
+++ b/WebHostLib/customserver.py
@@ -5,28 +5,36 @@
import datetime
import functools
import logging
+import multiprocessing
import pickle
import random
import socket
import threading
import time
import typing
+import sys
import websockets
from pony.orm import commit, db_session, select
import Utils
-from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
+from MultiServer import (
+ Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert,
+ server_per_message_deflate_factory,
+)
from Utils import restricted_loads, cache_argsless
+from .locker import Locker
from .models import Command, GameDataPackage, Room, db
class CustomClientMessageProcessor(ClientMessageProcessor):
ctx: WebHostContext
- def _cmd_video(self, platform, user):
- """Set a link for your name in the WebHostLib tracker pointing to a video stream"""
+ def _cmd_video(self, platform: str, user: str):
+ """Set a link for your name in the WebHostLib tracker pointing to a video stream.
+ Currently, only YouTube and Twitch platforms are supported.
+ """
if platform.lower().startswith("t"): # twitch
self.ctx.video[self.client.team, self.client.slot] = "Twitch", user
self.ctx.save()
@@ -49,24 +57,35 @@ def _cmd_video(self, platform, user):
class DBCommandProcessor(ServerCommandProcessor):
def output(self, text: str):
- logging.info(text)
+ self.ctx.logger.info(text)
class WebHostContext(Context):
room_id: int
- def __init__(self, static_server_data: dict):
+ def __init__(self, static_server_data: dict, logger: logging.Logger):
# static server data is used during _load_game_data to load required data,
# without needing to import worlds system, which takes quite a bit of memory
self.static_server_data = static_server_data
- super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
+ super(WebHostContext, self).__init__("", 0, "", "", 1,
+ 40, True, "enabled", "enabled",
+ "enabled", 0, 2, logger=logger)
del self.static_server_data
self.main_loop = asyncio.get_running_loop()
self.video = {}
self.tags = ["AP", "WebHost"]
+ def __del__(self):
+ try:
+ import psutil
+ from Utils import format_SI_prefix
+ self.logger.debug(f"Context destroyed, Mem: {format_SI_prefix(psutil.Process().memory_info().rss, 1024)}iB")
+ except ImportError:
+ self.logger.debug("Context destroyed")
+
def _load_game_data(self):
for key, value in self.static_server_data.items():
+ # NOTE: attributes are mutable and shared, so they will have to be copied before being modified
setattr(self, key, value)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
@@ -81,6 +100,7 @@ def listen_to_db_commands(self):
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
command.delete()
commit()
+ del commands
time.sleep(5)
@db_session
@@ -94,33 +114,56 @@ def load(self, room_id: int):
multidata = self.decompress(room.seed.multidata)
game_data_packages = {}
+
+ static_gamespackage = self.gamespackage # this is shared across all rooms
+ static_item_name_groups = self.item_name_groups
+ static_location_name_groups = self.location_name_groups
+ self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
+ self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
+ self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
+ missing_checksum = False
+
for game in list(multidata.get("datapackage", {})):
game_data = multidata["datapackage"][game]
if "checksum" in game_data:
- if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
- # non-custom. remove from multidata
+ if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
+ # non-custom. remove from multidata and use static data
# games package could be dropped from static data once all rooms embed data package
del multidata["datapackage"][game]
else:
row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
- game_data_packages[game] = Utils.restricted_loads(row.data)
-
+ game_data_packages[game] = restricted_loads(row.data)
+ continue
+ else:
+ self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
+ else:
+ missing_checksum = True # Game rolled on old AP and will load data package from multidata
+ self.gamespackage[game] = static_gamespackage.get(game, {})
+ self.item_name_groups[game] = static_item_name_groups.get(game, {})
+ self.location_name_groups[game] = static_location_name_groups.get(game, {})
+
+ if not game_data_packages and not missing_checksum:
+ # all static -> use the static dicts directly
+ self.gamespackage = static_gamespackage
+ self.item_name_groups = static_item_name_groups
+ self.location_name_groups = static_location_name_groups
return self._load(multidata, game_data_packages, True)
- @db_session
def init_save(self, enabled: bool = True):
self.saving = enabled
if self.saving:
- savegame_data = Room.get(id=self.room_id).multisave
- if savegame_data:
- self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
- self._start_async_saving()
+ with db_session:
+ savegame_data = Room.get(id=self.room_id).multisave
+ if savegame_data:
+ self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
+ self._start_async_saving(atexit_save=False)
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@db_session
def _save(self, exit_save: bool = False) -> bool:
room = Room.get(id=self.room_id)
+ # Does not use Utils.restricted_dumps because we'd rather make a save than not make one
room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
@@ -141,76 +184,205 @@ def get_random_port():
def get_static_server_data() -> dict:
import worlds
data = {
- "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()},
+ "non_hintable_names": {
+ world_name: world.hint_blacklist
+ for world_name, world in worlds.AutoWorldRegister.world_types.items()
+ },
+ "gamespackage": {
+ world_name: {
+ key: value
+ for key, value in game_package.items()
+ if key not in ("item_name_groups", "location_name_groups")
+ }
+ for world_name, game_package in worlds.network_data_package["games"].items()
+ },
+ "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["non_hintable_names"][world_name] = world.hint_blacklist
-
return data
-def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
+def set_up_logging(room_id) -> logging.Logger:
+ import os
+ # logger setup
+ logger = logging.getLogger(f"RoomLogger {room_id}")
+
+ # this *should* be empty, but just in case.
+ for handler in logger.handlers[:]:
+ logger.removeHandler(handler)
+ handler.close()
+
+ file_handler = logging.FileHandler(
+ os.path.join(Utils.user_path("logs"), f"{room_id}.txt"),
+ "a",
+ encoding="utf-8-sig")
+ file_handler.setFormatter(logging.Formatter("[%(asctime)s]: %(message)s"))
+ logger.setLevel(logging.INFO)
+ logger.addHandler(file_handler)
+ return logger
+
+
+def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
- host: str):
+ host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
+ from setproctitle import setproctitle
+
+ setproctitle(name)
+ Utils.init_logging(name)
+ try:
+ import resource
+ except ModuleNotFoundError:
+ pass # unix only module
+ else:
+ # Each Server is another file handle, so request as many as we can from the system
+ file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
+ # set soft limit to hard limit
+ resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
+ del resource, file_limit
+
# establish DB connection for multidata and multisave
db.bind(**ponyconfig)
db.generate_mapping(check_tables=False)
- async def main():
- Utils.init_logging(str(room_id), write_mode="a")
- ctx = WebHostContext(static_server_data)
- ctx.load(room_id)
- ctx.init_save()
- ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
- try:
- ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
-
- await ctx.server
- except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
- ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, 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:
- # Prefer IPv4, as most users seem to not have working ipv6 support
- if not port:
- port = socketname[1]
- elif wssocket.family == socket.AF_INET:
- 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, []))
- await ctx.shutdown_task
- logging.info("Shutting down")
-
- from .autolauncher import Locker
- with Locker(room_id):
- try:
- asyncio.run(main())
- except KeyboardInterrupt:
- with db_session:
- room = Room.get(id=room_id)
- # ensure the Room does not spin up again on its own, minute of safety buffer
- room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
- except:
- with db_session:
- room = Room.get(id=room_id)
- room.last_port = -1
- # ensure the Room does not spin up again on its own, minute of safety buffer
- room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
- raise
+ if "worlds" in sys.modules:
+ raise Exception("Worlds system should not be loaded in the custom server.")
+
+ import gc
+
+ if not cert_file:
+ def get_ssl_context():
+ return None
+ else:
+ load_date = None
+ ssl_context = load_server_cert(cert_file, cert_key_file)
+
+ def get_ssl_context():
+ nonlocal load_date, ssl_context
+ today = datetime.date.today()
+ if load_date != today:
+ ssl_context = load_server_cert(cert_file, cert_key_file)
+ load_date = today
+ return ssl_context
+
+ del ponyconfig
+ gc.collect() # free intermediate objects used during setup
+
+ loop = asyncio.get_event_loop()
+
+ async def start_room(room_id):
+ with Locker(f"RoomLocker {room_id}"):
+ try:
+ logger = set_up_logging(room_id)
+ ctx = WebHostContext(static_server_data, logger)
+ ctx.load(room_id)
+ ctx.init_save()
+ assert ctx.server is None
+ try:
+ ctx.server = websockets.serve(
+ functools.partial(server, ctx=ctx),
+ ctx.host,
+ ctx.port,
+ ssl=get_ssl_context(),
+ extensions=[server_per_message_deflate_factory],
+ )
+ await ctx.server
+ except OSError: # likely port in use
+ ctx.server = websockets.serve(
+ functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
+
+ await ctx.server
+ port = 0
+ for wssocket in ctx.server.ws_server.sockets:
+ socketname = wssocket.getsockname()
+ if wssocket.family == socket.AF_INET6:
+ # Prefer IPv4, as most users seem to not have working ipv6 support
+ if not port:
+ port = socketname[1]
+ elif wssocket.family == socket.AF_INET:
+ port = socketname[1]
+ if port:
+ ctx.logger.info(f'Hosting game at {host}:{port}')
+ with db_session:
+ room = Room.get(id=ctx.room_id)
+ room.last_port = port
+ del room
+ else:
+ ctx.logger.exception("Could not determine port. Likely hosting failure.")
+ with db_session:
+ ctx.auto_shutdown = Room.get(id=room_id).timeout
+ if ctx.saving:
+ setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
+ assert ctx.shutdown_task is None
+ ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
+ await ctx.shutdown_task
+
+ except (KeyboardInterrupt, SystemExit):
+ if ctx.saving:
+ ctx._save()
+ setattr(asyncio.current_task(), "save", None)
+ except Exception as e:
+ with db_session:
+ room = Room.get(id=room_id)
+ room.last_port = -1
+ del room
+ logger.exception(e)
+ raise
+ else:
+ if ctx.saving:
+ ctx._save()
+ setattr(asyncio.current_task(), "save", None)
+ finally:
+ try:
+ ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
+ ctx.exit_event.set() # make sure the saving thread stops at some point
+ # NOTE: async saving should probably be an async task and could be merged with shutdown_task
+ with db_session:
+ # ensure the Room does not spin up again on its own, minute of safety buffer
+ room = Room.get(id=room_id)
+ room.last_activity = datetime.datetime.utcnow() - \
+ datetime.timedelta(minutes=1, seconds=room.timeout)
+ del room
+ logging.info(f"Shutting down room {room_id} on {name}.")
+ finally:
+ await asyncio.sleep(5)
+ rooms_shutting_down.put(room_id)
+
+ class Starter(threading.Thread):
+ _tasks: typing.List[asyncio.Future]
+
+ def __init__(self):
+ super().__init__()
+ self._tasks = []
+
+ def _done(self, task: asyncio.Future):
+ self._tasks.remove(task)
+ task.result()
+
+ def run(self):
+ while 1:
+ next_room = rooms_to_run.get(block=True, timeout=None)
+ gc.collect()
+ task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
+ self._tasks.append(task)
+ task.add_done_callback(self._done)
+ logging.info(f"Starting room {next_room} on {name}.")
+ del task # delete reference to task object
+
+ starter = Starter()
+ starter.daemon = True
+ starter.start()
+ try:
+ loop.run_forever()
+ finally:
+ # save all tasks that want to be saved during shutdown
+ for task in asyncio.all_tasks(loop):
+ save: typing.Optional[typing.Callable[[], typing.Any]] = getattr(task, "save", None)
+ if save:
+ save()
diff --git a/WebHostLib/downloads.py b/WebHostLib/downloads.py
index 5cf503be1b2b..388a6dc73cb1 100644
--- a/WebHostLib/downloads.py
+++ b/WebHostLib/downloads.py
@@ -61,12 +61,7 @@ def download_slot_file(room_id, player_id: int):
else:
import io
- 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['HOST_ADDRESS'], port=room.last_port)
- return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
- elif slot_data.game == "Factorio":
+ if slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist():
if name.endswith("info.json"):
@@ -90,6 +85,8 @@ def download_slot_file(room_id, player_id: int):
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
elif slot_data.game == "Kingdom Hearts 2":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
+ elif slot_data.game == "Final Fantasy Mystic Quest":
+ fname = f"AP+{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmq"
else:
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py
index 91d7594a1f23..f80663ff43f1 100644
--- a/WebHostLib/generate.py
+++ b/WebHostLib/generate.py
@@ -1,47 +1,45 @@
+import concurrent.futures
import json
import os
-import pickle
import random
import tempfile
import zipfile
-import concurrent.futures
from collections import Counter
-from typing import Dict, Optional, Any, Union, List
+from pickle import PicklingError
+from typing import Any
-from flask import request, flash, redirect, url_for, session, render_template
+from flask import flash, redirect, render_template, request, session, url_for
from pony.orm import commit, db_session
-from BaseClasses import seeddigits, get_seed
-from Generate import handle_name, PlandoOptions
+from BaseClasses import get_seed, seeddigits
+from Generate import PlandoOptions, handle_name, mystery_argparse
from Main import main as ERmain
-from Utils import __version__
+from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
from WebHostLib import app
-from worlds.alttp.EntranceRandomizer import parse_arguments
+from settings import ServerOptions, GeneratorOptions
from .check import get_yaml_data, roll_options
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db
-def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
- plando_options = {
- options_source.get("plando_bosses", ""),
- options_source.get("plando_items", ""),
- options_source.get("plando_connections", ""),
- options_source.get("plando_texts", "")
- }
- plando_options -= {""}
+def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] | dict[str, Any]]:
+ plando_options: set[str] = set()
+ for substr in ("bosses", "items", "connections", "texts"):
+ if options_source.get(f"plando_{substr}", substr in GeneratorOptions.plando_options):
+ plando_options.add(substr)
server_options = {
- "hint_cost": int(options_source.get("hint_cost", 10)),
- "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))),
- "server_password": options_source.get("server_password", None),
+ "hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)),
+ "release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
+ "remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
+ "collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
+ "countdown_mode": str(options_source.get("countdown_mode", ServerOptions.countdown_mode)),
+ "item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
+ "server_password": str(options_source.get("server_password", None)),
}
generator_options = {
- "spoiler": int(options_source.get("spoiler", 0)),
- "race": race
+ "spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),
+ "race": race,
}
if race:
@@ -64,46 +62,65 @@ def generate(race=False):
if 'file' not in request.files:
flash('No file part')
else:
- file = request.files['file']
- options = get_yaml_data(file)
+ files = request.files.getlist('file')
+ options = get_yaml_data(files)
if isinstance(options, str):
flash(options)
else:
meta = get_meta(request.form, race)
- results, gen_options = roll_options(options, set(meta["plando_options"]))
-
- if any(type(result) == str for result in results.values()):
- return render_template("checkResult.html", results=results)
- elif len(gen_options) > app.config["MAX_ROLL"]:
- flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
- f"If you have a larger group, please generate it yourself and upload it.")
- elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
- gen = Generation(
- options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
- # convert to json compatible
- meta=json.dumps(meta),
- state=STATE_QUEUED,
- owner=session["_id"])
- commit()
+ return start_generation(options, meta)
+
+ return render_template("generate.html", race=race, version=__version__)
- return redirect(url_for("wait_seed", seed=gen.id))
- else:
- try:
- seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
- meta=meta, owner=session["_id"].int)
- except BaseException as e:
- from .autolauncher import handle_generation_failure
- handle_generation_failure(e)
- return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
- return redirect(url_for("view_seed", seed=seed_id))
+def format_exception(e: BaseException) -> str:
+ return f"{e.__class__.__name__}: {e}"
+
+
+def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
+ results, gen_options = roll_options(options, set(meta["plando_options"]))
+
+ if any(type(result) == str for result in results.values()):
+ return render_template("checkResult.html", results=results)
+ elif len(gen_options) > app.config["MAX_ROLL"]:
+ flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
+ f"If you have a larger group, please generate it yourself and upload it.")
+ return redirect(url_for(request.endpoint, **(request.view_args or {})))
+ elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
+ try:
+ gen = Generation(
+ options=restricted_dumps({name: vars(options) for name, options in gen_options.items()}),
+ # convert to json compatible
+ meta=json.dumps(meta),
+ state=STATE_QUEUED,
+ owner=session["_id"])
+ except PicklingError as e:
+ from .autolauncher import handle_generation_failure
+ handle_generation_failure(e)
+ meta["error"] = format_exception(e)
+ details = json.dumps(meta, indent=4).strip()
+ return render_template("seedError.html", seed_error=meta["error"], details=details)
+
+ commit()
+
+ return redirect(url_for("wait_seed", seed=gen.id))
+ else:
+ try:
+ seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
+ meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"])
+ except BaseException as e:
+ from .autolauncher import handle_generation_failure
+ handle_generation_failure(e)
+ meta["error"] = format_exception(e)
+ details = json.dumps(meta, indent=4).strip()
+ return render_template("seedError.html", seed_error=meta["error"], details=details)
- return render_template("generate.html", race=race, version=__version__)
+ return redirect(url_for("view_seed", seed=seed_id))
-def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
- if not meta:
- meta: Dict[str, Any] = {}
+def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None):
+ if meta is None:
+ meta = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("generator_options", {}).setdefault("race", False)
@@ -120,40 +137,47 @@ def task():
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
- erargs = parse_arguments(['--multi', str(playercount)])
- erargs.seed = seed
- erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
- erargs.spoiler = meta["generator_options"].get("spoiler", 0)
- erargs.race = race
- erargs.outputname = seedname
- erargs.outputpath = target.name
- erargs.teams = 1
- erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
- {"bosses", "items", "connections", "texts"}))
- erargs.skip_prog_balancing = False
+ args = mystery_argparse([]) # Just to set up the Namespace with defaults
+ args.multi = playercount
+ args.seed = seed
+ args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
+ args.spoiler = meta["generator_options"].get("spoiler", 0)
+ args.race = race
+ args.outputname = seedname
+ args.outputpath = target.name
+ args.teams = 1
+ args.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
+ {"bosses", "items", "connections", "texts"}))
+ args.skip_prog_balancing = False
+ args.skip_output = False
+ args.spoiler_only = False
+ args.csv_output = False
+ args.sprite = dict.fromkeys(range(1, args.multi+1), None)
+ args.sprite_pool = dict.fromkeys(range(1, args.multi+1), None)
name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
for k, v in settings.items():
if v is not None:
- if hasattr(erargs, k):
- getattr(erargs, k)[player] = v
+ if hasattr(args, k):
+ getattr(args, k)[player] = v
else:
- setattr(erargs, k, {player: v})
+ setattr(args, k, {player: v})
- if not erargs.name[player]:
- erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
- erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
- if len(set(erargs.name.values())) != len(erargs.name):
- raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
- ERmain(erargs, seed, baked_server_options=meta["server_options"])
+ if not args.name[player]:
+ args.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
+ args.name[player] = handle_name(args.name[player], player, name_counter)
+ if len(set(args.name.values())) != len(args.name):
+ raise Exception(f"Names have to be unique. Names: {Counter(args.name.values())}")
+ ERmain(args, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race)
- thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
+
+ thread_pool = DaemonThreadPoolExecutor(max_workers=1)
thread = thread_pool.submit(task)
try:
- return thread.result(app.config["JOB_TIME"])
+ return thread.result(timeout)
except concurrent.futures.TimeoutError as e:
if sid:
with db_session:
@@ -161,11 +185,14 @@ def task():
if gen is not None:
gen.state = STATE_ERROR
meta = json.loads(gen.meta)
- meta["error"] = (
- "Allowed time for Generation exceeded, please consider generating locally instead. " +
- e.__class__.__name__ + ": " + str(e))
+ meta["error"] = ("Allowed time for Generation exceeded, " +
+ "please consider generating locally instead. " +
+ format_exception(e))
gen.meta = json.dumps(meta)
commit()
+ except (KeyboardInterrupt, SystemExit):
+ # don't update db, retry next time
+ raise
except BaseException as e:
if sid:
with db_session:
@@ -173,10 +200,15 @@ def task():
if gen is not None:
gen.state = STATE_ERROR
meta = json.loads(gen.meta)
- meta["error"] = (e.__class__.__name__ + ": " + str(e))
+ meta["error"] = format_exception(e)
gen.meta = json.dumps(meta)
commit()
raise
+ finally:
+ # free resources claimed by thread pool, if possible
+ # NOTE: Timeout depends on the process being killed at some point
+ # since we can't actually cancel a running gen at the moment.
+ thread_pool.shutdown(wait=False, cancel_futures=True)
@app.route('/wait/')
@@ -190,7 +222,9 @@ def wait_seed(seed: UUID):
if not generation:
return "Generation not found."
elif generation.state == STATE_ERROR:
- return render_template("seedError.html", seed_error=generation.meta)
+ meta = json.loads(generation.meta)
+ details = json.dumps(meta, indent=4).strip()
+ return render_template("seedError.html", seed_error=meta["error"], details=details)
return render_template("waitSeed.html", seed_id=seed_id)
diff --git a/WebHostLib/locker.py b/WebHostLib/locker.py
new file mode 100644
index 000000000000..5293352887d3
--- /dev/null
+++ b/WebHostLib/locker.py
@@ -0,0 +1,51 @@
+import os
+import sys
+
+
+class CommonLocker:
+ """Uses a file lock to signal that something is already running"""
+ lock_folder = "file_locks"
+
+ def __init__(self, lockname: str, folder=None):
+ if folder:
+ self.lock_folder = folder
+ os.makedirs(self.lock_folder, exist_ok=True)
+ self.lockname = lockname
+ self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
+
+
+class AlreadyRunningException(Exception):
+ pass
+
+
+if sys.platform == 'win32':
+ class Locker(CommonLocker):
+ def __enter__(self):
+ try:
+ if os.path.exists(self.lockfile):
+ os.unlink(self.lockfile)
+ self.fp = os.open(
+ self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
+ except OSError as e:
+ raise AlreadyRunningException() from e
+
+ def __exit__(self, _type, value, tb):
+ fp = getattr(self, "fp", None)
+ if fp:
+ os.close(self.fp)
+ os.unlink(self.lockfile)
+else: # unix
+ import fcntl
+
+
+ class Locker(CommonLocker):
+ def __enter__(self):
+ try:
+ self.fp = open(self.lockfile, "wb")
+ fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError as e:
+ raise AlreadyRunningException() from e
+
+ def __exit__(self, _type, value, tb):
+ fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN)
+ self.fp.close()
diff --git a/WebHostLib/lttpsprites.py b/WebHostLib/lttpsprites.py
index 1b8ee4cf487c..3bf596db4804 100644
--- a/WebHostLib/lttpsprites.py
+++ b/WebHostLib/lttpsprites.py
@@ -3,10 +3,10 @@
import json
from Utils import local_path, user_path
-from worlds.alttp.Rom import Sprite
def update_sprites_lttp():
+ from worlds.alttp.Rom import Sprite
from tkinter import Tk
from LttPAdjuster import get_image_for_sprite
from LttPAdjuster import BackgroundTaskProgress
@@ -14,7 +14,7 @@ def update_sprites_lttp():
from LttPAdjuster import update_sprites
# Target directories
- input_dir = user_path("data", "sprites", "alttpr")
+ input_dir = user_path("data", "sprites", "alttp", "remote")
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
diff --git a/WebHostLib/markdown.py b/WebHostLib/markdown.py
new file mode 100644
index 000000000000..ff7a5fd7c5d5
--- /dev/null
+++ b/WebHostLib/markdown.py
@@ -0,0 +1,90 @@
+import re
+from collections import Counter
+
+import mistune
+from werkzeug.utils import secure_filename
+
+
+__all__ = [
+ "ImgUrlRewriteInlineParser",
+ 'render_markdown',
+]
+
+
+class ImgUrlRewriteInlineParser(mistune.InlineParser):
+ relative_url_base: str
+
+ def __init__(self, relative_url_base: str, hard_wrap: bool = False) -> None:
+ super().__init__(hard_wrap)
+ self.relative_url_base = relative_url_base
+
+ @staticmethod
+ def _find_game_name_by_folder_name(name: str) -> str | None:
+ from worlds.AutoWorld import AutoWorldRegister
+
+ for world_name, world_type in AutoWorldRegister.world_types.items():
+ if world_type.__module__ == f"worlds.{name}":
+ return world_name
+ return None
+
+ def parse_link(self, m: re.Match[str], state: mistune.InlineState) -> int | None:
+ res = super().parse_link(m, state)
+ if res is not None and state.tokens and state.tokens[-1]["type"] == "image":
+ image_token = state.tokens[-1]
+ url: str = image_token["attrs"]["url"]
+ if not url.startswith("/") and not "://" in url:
+ # replace relative URL to another world's doc folder with the webhost folder layout
+ if url.startswith("../../") and "/docs/" in self.relative_url_base:
+ parts = url.split("/", 4)
+ if parts[2] != ".." and parts[3] == "docs":
+ game_name = self._find_game_name_by_folder_name(parts[2])
+ if game_name is not None:
+ url = "/".join(parts[1:2] + [secure_filename(game_name)] + parts[4:])
+ # change relative URL to point to deployment folder
+ url = f"{self.relative_url_base}/{url}"
+ image_token['attrs']['url'] = url
+ return res
+
+
+def render_markdown(path: str, img_url_base: str | None = None) -> str:
+ markdown = mistune.create_markdown(
+ escape=False,
+ plugins=[
+ "strikethrough",
+ "footnotes",
+ "table",
+ "speedup",
+ ],
+ )
+
+ heading_id_count: Counter[str] = Counter()
+
+ def heading_id(text: str) -> str:
+ nonlocal heading_id_count
+
+ # there is no good way to do this without regex
+ s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
+ n = heading_id_count[s]
+ heading_id_count[s] += 1
+ if n > 0:
+ s += f"-{n}"
+ return s
+
+ def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
+ for tok in state.tokens:
+ if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
+ text = tok["text"]
+ assert isinstance(text, str)
+ unique_id = heading_id(text)
+ tok["attrs"]["id"] = unique_id
+ tok["text"] = f"{text} " # make header link to itself
+
+ markdown.before_render_hooks.append(id_hook)
+ if img_url_base:
+ markdown.inline = ImgUrlRewriteInlineParser(img_url_base)
+
+ with open(path, encoding="utf-8-sig") as f:
+ document = f.read()
+ html = markdown(document)
+ assert isinstance(html, str), "Unexpected mistune renderer in render_markdown"
+ return html
diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py
index 6d3e82c00c6d..82faaf2b164e 100644
--- a/WebHostLib/misc.py
+++ b/WebHostLib/misc.py
@@ -1,27 +1,47 @@
import datetime
import os
-from typing import List, Dict, Union
+import warnings
+from enum import StrEnum
+from typing import Any, IO, Dict, Iterator, List, Tuple, Union
import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from pony.orm import count, commit, db_session
+from werkzeug.utils import secure_filename
-from worlds.AutoWorld import AutoWorldRegister
+from worlds.AutoWorld import AutoWorldRegister, World
from . import app, cache
+from .markdown import render_markdown
from .models import Seed, Room, Command, UUID, uuid4
-
-
-def get_world_theme(game_name: str):
- if game_name in AutoWorldRegister.world_types:
- return AutoWorldRegister.world_types[game_name].web.theme
- return 'grass'
-
-
-@app.before_request
-def register_session():
- session.permanent = True # technically 31 days after the last visit
- if not session.get("_id", None):
- session["_id"] = uuid4() # uniquely identify each session without needing a login
+from Utils import title_sorted
+
+class WebWorldTheme(StrEnum):
+ DIRT = "dirt"
+ GRASS = "grass"
+ GRASS_FLOWERS = "grassFlowers"
+ ICE = "ice"
+ JUNGLE = "jungle"
+ OCEAN = "ocean"
+ PARTY_TIME = "partyTime"
+ STONE = "stone"
+
+def get_world_theme(game_name: str) -> str:
+ if game_name not in AutoWorldRegister.world_types:
+ return "grass"
+ chosen_theme = AutoWorldRegister.world_types[game_name].web.theme
+ available_themes = [theme.value for theme in WebWorldTheme]
+ if chosen_theme not in available_themes:
+ warnings.warn(f"Theme '{chosen_theme}' for {game_name} not valid, switching to default 'grass' theme.")
+ return "grass"
+ return chosen_theme
+
+
+def get_visible_worlds() -> dict[str, type(World)]:
+ worlds = {}
+ for game, world in AutoWorldRegister.world_types.items():
+ if not world.hidden:
+ worlds[game] = world
+ return worlds
@app.errorhandler(404)
@@ -32,55 +52,107 @@ def page_not_found(err):
# Start Playing Page
@app.route('/start-playing')
+@cache.cached()
def start_playing():
return render_template(f"startPlaying.html")
-@app.route('/weighted-settings')
-def weighted_settings():
- return render_template(f"weighted-settings.html")
-
-
-# Player settings pages
-@app.route('/games//player-settings')
-def player_settings(game):
- return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
-
-
-# Game Info Pages
@app.route('/games//info/')
+@cache.cached()
def game_info(game, lang):
- return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
+ """Game Info Pages"""
+ try:
+ theme = get_world_theme(game)
+ secure_game_name = secure_filename(game)
+ lang = secure_filename(lang)
+ file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
+ file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
+ document = render_markdown(os.path.join(file_dir, f"{lang}_{secure_game_name}.md"), file_dir_url)
+ return render_template(
+ "markdown_document.html",
+ title=f"{game} Guide",
+ html_from_markdown=document,
+ theme=theme,
+ )
+ except FileNotFoundError:
+ return abort(404)
-# List of supported games
@app.route('/games')
+@cache.cached()
def games():
- worlds = {}
- for game, world in AutoWorldRegister.world_types.items():
- if not world.hidden:
- worlds[game] = world
- return render_template("supportedGames.html", worlds=worlds)
+ """List of supported games"""
+ return render_template("supportedGames.html", worlds=get_visible_worlds())
+
+
+@app.route('/tutorial//')
+@cache.cached()
+def tutorial(game: str, file: str):
+ try:
+ theme = get_world_theme(game)
+ secure_game_name = secure_filename(game)
+ file = secure_filename(file)
+ file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
+ file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
+ document = render_markdown(os.path.join(file_dir, f"{file}.md"), file_dir_url)
+ return render_template(
+ "markdown_document.html",
+ title=f"{game} Guide",
+ html_from_markdown=document,
+ theme=theme,
+ )
+ except FileNotFoundError:
+ return abort(404)
@app.route('/tutorial///')
-def tutorial(game, file, lang):
- return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
+def tutorial_redirect(game: str, file: str, lang: str):
+ """
+ Permanent redirect old tutorial URLs to new ones to keep search engines happy.
+ e.g. /tutorial/Archipelago/setup/en -> /tutorial/Archipelago/setup_en
+ """
+ return redirect(url_for("tutorial", game=game, file=f"{file}_{lang}"), code=301)
@app.route('/tutorial/')
+@cache.cached()
def tutorial_landing():
- return render_template("tutorialLanding.html")
+ tutorials = {}
+ worlds = AutoWorldRegister.world_types
+ for world_name, world_type in worlds.items():
+ current_world = tutorials[world_name] = {}
+ for tutorial in world_type.web.tutorials:
+ current_tutorial = current_world.setdefault(tutorial.tutorial_name, {
+ "description": tutorial.description, "files": {}})
+ current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = {
+ "authors": tutorial.authors,
+ "language": tutorial.language
+ }
+ tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
+ tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)}
+ return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
@app.route('/faq//')
-def faq(lang):
- return render_template("faq.html", lang=lang)
+@cache.cached()
+def faq(lang: str):
+ document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md"))
+ return render_template(
+ "markdown_document.html",
+ title="Frequently Asked Questions",
+ html_from_markdown=document,
+ )
@app.route('/glossary//')
-def terms(lang):
- return render_template("glossary.html", lang=lang)
+@cache.cached()
+def glossary(lang: str):
+ document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md"))
+ return render_template(
+ "markdown_document.html",
+ title="Glossary",
+ html_from_markdown=document,
+ )
@app.route('/seed/')
@@ -101,53 +173,99 @@ def new_room(seed: UUID):
return redirect(url_for("host_room", room=room.id))
-def _read_log(path: str):
- if os.path.exists(path):
- with open(path, encoding="utf-8-sig") as log:
- yield from log
- else:
- yield f"Logfile {path} does not exist. " \
- f"Likely a crash during spinup of multiworld instance or it is still spinning up."
+def _read_log(log: IO[Any], offset: int = 0) -> Iterator[bytes]:
+ marker = log.read(3) # skip optional BOM
+ if marker != b'\xEF\xBB\xBF':
+ log.seek(0, os.SEEK_SET)
+ log.seek(offset, os.SEEK_CUR)
+ yield from log
+ log.close() # free file handle as soon as possible
@app.route('/log/')
-def display_log(room: UUID):
+def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
file_path = os.path.join("logs", str(room.id) + ".txt")
- if os.path.exists(file_path):
- return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
- return "Log File does not exist."
+ try:
+ log = open(file_path, "rb")
+ range_header = request.headers.get("Range")
+ if range_header:
+ range_type, range_values = range_header.split('=')
+ start, end = map(str.strip, range_values.split('-', 1))
+ if range_type != "bytes" or end != "":
+ return "Unsupported range", 500
+ # NOTE: we skip Content-Range in the response here, which isn't great but works for our JS
+ return Response(_read_log(log, int(start)), mimetype="text/plain", status=206)
+ return Response(_read_log(log), mimetype="text/plain")
+ except FileNotFoundError:
+ return Response(f"Logfile {file_path} does not exist. "
+ f"Likely a crash during spinup of multiworld instance or it is still spinning up.",
+ mimetype="text/plain")
return "Access Denied", 403
-@app.route('/room/', methods=['GET', 'POST'])
+@app.post("/room/")
+def host_room_command(room: UUID):
+ room: Room = Room.get(id=room)
+ if room is None:
+ return abort(404)
+
+ if room.owner == session["_id"]:
+ cmd = request.form["cmd"]
+ if cmd:
+ Command(room=room, commandtext=cmd)
+ commit()
+ return redirect(url_for("host_room", room=room.id))
+
+
+@app.get("/room/")
def host_room(room: UUID):
room: Room = Room.get(id=room)
if room is None:
return abort(404)
- if request.method == "POST":
- if room.owner == session["_id"]:
- cmd = request.form["cmd"]
- if cmd:
- Command(room=room, commandtext=cmd)
- commit()
now = datetime.datetime.utcnow()
# indicate that the page should reload to get the assigned port
- should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
- with db_session:
+ should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
+ or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
+
+ if now - room.last_activity > datetime.timedelta(minutes=1):
+ # we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error
+ # due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"
room.last_activity = now # will trigger a spinup, if it's not already running
- return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
+ browser_tokens = "Mozilla", "Chrome", "Safari"
+ automated = ("update" in request.args
+ or "Discordbot" in request.user_agent.string
+ or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
+
+ def get_log(max_size: int = 0 if automated else 1024000) -> Tuple[str, int]:
+ if max_size == 0:
+ return "âĻ", 0
+ try:
+ with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
+ raw_size = 0
+ fragments: List[str] = []
+ for block in _read_log(log):
+ if raw_size + len(block) > max_size:
+ fragments.append("âĻ")
+ break
+ raw_size += len(block)
+ fragments.append(block.decode("utf-8"))
+ return "".join(fragments), raw_size
+ except FileNotFoundError:
+ return "", 0
+
+ return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
@app.route('/favicon.ico')
def favicon():
- return send_from_directory(os.path.join(app.root_path, 'static/static'),
+ return send_from_directory(os.path.join(app.root_path, "static", "static"),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
@@ -167,10 +285,11 @@ def get_datapackage():
@app.route('/index')
@app.route('/sitemap')
+@cache.cached()
def get_sitemap():
available_games: List[Dict[str, Union[str, bool]]] = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
- has_settings: bool = isinstance(world.web.settings_page, bool) and world.web.settings_page
+ has_settings: bool = isinstance(world.web.options_page, bool) and world.web.options_page
available_games.append({ 'title': game, 'has_settings': has_settings })
return render_template("siteMap.html", games=available_games)
diff --git a/WebHostLib/options.py b/WebHostLib/options.py
index fca01407e06b..e3d2745855cd 100644
--- a/WebHostLib/options.py
+++ b/WebHostLib/options.py
@@ -1,148 +1,287 @@
+import collections.abc
import json
-import logging
import os
-import typing
+from textwrap import dedent
+from typing import Dict, Union
+from docutils.core import publish_parts
import yaml
-from jinja2 import Template
+from flask import redirect, render_template, request, Response, abort
import Options
-from Utils import __version__, local_path
+from Utils import local_path
from worlds.AutoWorld import AutoWorldRegister
+from . import app, cache
+from .generate import get_meta
+from .misc import get_world_theme
-handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
- "exclude_locations", "priority_locations"}
-
-def create():
+def create() -> None:
target_folder = local_path("WebHostLib", "static", "generated")
yaml_folder = os.path.join(target_folder, "configs")
Options.generate_yaml_templates(yaml_folder)
- def get_html_doc(option_type: type(Options.Option)) -> str:
- if not option_type.__doc__:
- return "Please document me!"
- return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
-
- weighted_settings = {
- "baseOptions": {
- "description": "Generated by https://archipelago.gg/",
- "name": "Player",
- "game": {},
- },
- "games": {},
- }
-
- for game_name, world in AutoWorldRegister.world_types.items():
-
- all_options: typing.Dict[str, Options.AssembleOptions] = {
- **Options.per_game_common_options,
- **world.option_definitions
- }
- # Generate JSON files for player-settings pages
- player_settings = {
- "baseOptions": {
- "description": f"Generated by https://archipelago.gg/ for {game_name}",
- "game": game_name,
- "name": "Player",
- },
+def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
+ world = AutoWorldRegister.world_types[world_name]
+ if world.hidden or world.web.options_page is False:
+ return redirect("games")
+ visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui
+
+ start_collapsed = {"Game Options": False}
+ for group in world.web.option_groups:
+ start_collapsed[group.name] = group.start_collapsed
+
+ return render_template(
+ template,
+ world_name=world_name,
+ world=world,
+ option_groups=Options.get_option_groups(world, visibility_level=visibility_flag),
+ start_collapsed=start_collapsed,
+ issubclass=issubclass,
+ Options=Options,
+ theme=get_world_theme(world_name),
+ )
+
+
+def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]:
+ from .generate import start_generation
+ return start_generation(options, get_meta({}))
+
+
+def send_yaml(player_name: str, formatted_options: dict) -> Response:
+ response = Response(yaml.dump(formatted_options, sort_keys=False))
+ response.headers["Content-Type"] = "text/yaml"
+ response.headers["Content-Disposition"] = f"attachment; filename={player_name}.yaml"
+ return response
+
+
+@app.template_filter("dedent")
+def filter_dedent(text: str) -> str:
+ return dedent(text).strip("\n ")
+
+
+@app.template_filter("rst_to_html")
+def filter_rst_to_html(text: str) -> str:
+ """Converts reStructuredText (such as a Python docstring) to HTML."""
+ if text.startswith(" ") or text.startswith("\t"):
+ text = dedent(text)
+ elif "\n" in text:
+ lines = text.splitlines()
+ text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
+
+ return publish_parts(text, writer='html', settings=None, settings_overrides={
+ 'raw_enable': False,
+ 'file_insertion_enabled': False,
+ 'output_encoding': 'unicode'
+ })['body']
+
+
+@app.template_test("ordered")
+def test_ordered(obj):
+ return isinstance(obj, collections.abc.Sequence)
+
+
+@app.route("/games//option-presets", methods=["GET"])
+@cache.cached()
+def option_presets(game: str) -> Response:
+ world = AutoWorldRegister.world_types[game]
+
+ presets = {}
+ for preset_name, preset in world.web.options_presets.items():
+ presets[preset_name] = {}
+ for preset_option_name, preset_option in preset.items():
+ if preset_option == "random":
+ presets[preset_name][preset_option_name] = preset_option
+ continue
+
+ option = world.options_dataclass.type_hints[preset_option_name].from_any(preset_option)
+ if isinstance(option, Options.NamedRange) and isinstance(preset_option, str):
+ assert preset_option in option.special_range_names, \
+ f"Invalid preset value '{preset_option}' for '{preset_option_name}' in '{preset_name}'. " \
+ f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
+
+ presets[preset_name][preset_option_name] = option.value
+ elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.OptionCounter)):
+ presets[preset_name][preset_option_name] = option.value
+ elif isinstance(preset_option, str):
+ # Ensure the option value is valid for Choice and Toggle options
+ assert option.name_lookup[option.value] == preset_option, \
+ f"Invalid option value '{preset_option}' for '{preset_option_name}' in preset '{preset_name}'. " \
+ f"Values must not be resolved to a different option via option.from_text (or an alias)."
+ # Use the name of the option
+ presets[preset_name][preset_option_name] = option.current_key
+ else:
+ # Use the name of the option
+ presets[preset_name][preset_option_name] = option.current_key
+
+ class SetEncoder(json.JSONEncoder):
+ def default(self, obj):
+ from collections.abc import Set
+ if isinstance(obj, Set):
+ return list(obj)
+ return json.JSONEncoder.default(self, obj)
+
+ json_data = json.dumps(presets, cls=SetEncoder)
+ response = Response(json_data)
+ response.headers["Content-Type"] = "application/json"
+ return response
+
+
+@app.route("/weighted-options")
+def weighted_options_old():
+ return redirect("games", 301)
+
+
+@app.route("/games//weighted-options")
+@cache.cached()
+def weighted_options(game: str):
+ try:
+ return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
+ except KeyError:
+ return abort(404)
+
+
+@app.route("/games//generate-weighted-yaml", methods=["POST"])
+def generate_weighted_yaml(game: str):
+ if request.method == "POST":
+ intent_generate = False
+ options = {}
+
+ for key, val in request.form.items():
+ if val == "_ensure-empty-list":
+ options[key] = {}
+ elif "||" not in key:
+ if len(str(val)) == 0:
+ continue
+
+ options[key] = val
+ else:
+ if int(val) == 0:
+ continue
+
+ [option, setting] = key.split("||")
+ options.setdefault(option, {})[setting] = int(val)
+
+ # Error checking
+ if "name" not in options:
+ return "Player name is required."
+
+ # Remove POST data irrelevant to YAML
+ if "intent-generate" in options:
+ intent_generate = True
+ del options["intent-generate"]
+ if "intent-export" in options:
+ del options["intent-export"]
+
+ # Properly format YAML output
+ player_name = options["name"]
+ del options["name"]
+
+ formatted_options = {
+ "name": player_name,
+ "game": game,
+ "description": f"Generated by https://archipelago.gg/ for {game}",
+ game: options,
}
- game_options = {}
- for option_name, option in all_options.items():
- if option_name in handled_in_js:
- pass
-
- elif issubclass(option, Options.Choice) or issubclass(option, Options.Toggle):
- game_options[option_name] = this_option = {
- "type": "select",
- "displayName": option.display_name if hasattr(option, "display_name") else option_name,
- "description": get_html_doc(option),
- "defaultValue": None,
- "options": []
- }
-
- for sub_option_id, sub_option_name in option.name_lookup.items():
- if sub_option_name != "random":
- this_option["options"].append({
- "name": option.get_option_name(sub_option_id),
- "value": sub_option_name,
- })
- if sub_option_id == option.default:
- this_option["defaultValue"] = sub_option_name
-
- if not this_option["defaultValue"]:
- this_option["defaultValue"] = "random"
-
- elif issubclass(option, Options.Range):
- game_options[option_name] = {
- "type": "range",
- "displayName": option.display_name if hasattr(option, "display_name") else option_name,
- "description": get_html_doc(option),
- "defaultValue": option.default if hasattr(
- option, "default") and option.default != "random" else option.range_start,
- "min": option.range_start,
- "max": option.range_end,
- }
-
- if issubclass(option, Options.SpecialRange):
- game_options[option_name]["type"] = 'special_range'
- game_options[option_name]["value_names"] = {}
- for key, val in option.special_range_names.items():
- game_options[option_name]["value_names"][key] = val
-
- elif issubclass(option, Options.ItemSet):
- game_options[option_name] = {
- "type": "items-list",
- "displayName": option.display_name if hasattr(option, "display_name") else option_name,
- "description": get_html_doc(option),
- "defaultValue": list(option.default)
- }
-
- elif issubclass(option, Options.LocationSet):
- game_options[option_name] = {
- "type": "locations-list",
- "displayName": option.display_name if hasattr(option, "display_name") else option_name,
- "description": get_html_doc(option),
- "defaultValue": list(option.default)
- }
-
- elif issubclass(option, Options.VerifyKeys) and not issubclass(option, Options.OptionDict):
- if option.valid_keys:
- game_options[option_name] = {
- "type": "custom-list",
- "displayName": option.display_name if hasattr(option, "display_name") else option_name,
- "description": get_html_doc(option),
- "options": list(option.valid_keys),
- "defaultValue": list(option.default) if hasattr(option, "default") else []
- }
+ if intent_generate:
+ return generate_game({player_name: formatted_options})
+ else:
+ return send_yaml(player_name, formatted_options)
+
+
+# Player options pages
+@app.route("/games//player-options")
+@cache.cached()
+def player_options(game: str):
+ try:
+ return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
+ except KeyError:
+ return abort(404)
+
+
+# YAML generator for player-options
+@app.route("/games//generate-yaml", methods=["POST"])
+def generate_yaml(game: str):
+ if request.method == "POST":
+ options = {}
+ intent_generate = False
+
+ for key, val in request.form.items(multi=True):
+ if val == "_ensure-empty-list":
+ options[key] = []
+ elif options.get(key):
+ if not isinstance(options[key], list):
+ options[key] = [options[key]]
+ options[key].append(val)
else:
- logging.debug(f"{option} not exported to Web Settings.")
+ options[key] = val
+
+ for key, val in options.copy().items():
+ key_parts = key.rsplit("||", 2)
+ # Detect and build OptionCounter options from their name pattern
+ if key_parts[-1] == "qty":
+ if key_parts[0] not in options:
+ options[key_parts[0]] = {}
+ if val and val != "0":
+ options[key_parts[0]][key_parts[1]] = int(val)
+ del options[key]
- player_settings["gameOptions"] = game_options
+ # Detect keys which end with -custom, indicating a TextChoice with a possible custom value
+ elif key_parts[-1].endswith("-custom"):
+ if val:
+ options[key_parts[-1][:-7]] = val
- os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True)
+ del options[key]
- with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
- json.dump(player_settings, f, indent=2, separators=(',', ': '))
+ # Detect keys which end with -range, indicating a NamedRange with a possible custom value
+ elif key_parts[-1].endswith("-range"):
+ if options[key_parts[-1][:-6]] == "custom":
+ options[key_parts[-1][:-6]] = val
- if not world.hidden and world.web.settings_page is True:
- # Add the random option to Choice, TextChoice, and Toggle settings
- for option in game_options.values():
- if option["type"] == "select":
- option["options"].append({"name": "Random", "value": "random"})
+ del options[key]
- if not option["defaultValue"]:
- option["defaultValue"] = "random"
+ # Detect random-* keys and set their options accordingly
+ for key, val in options.copy().items():
+ if key.startswith("random-"):
+ options[key.removeprefix("random-")] = "random"
+ del options[key]
+
+ # Error checking
+ if not options["name"]:
+ return "Player name is required."
+
+ # Remove POST data irrelevant to YAML
+ preset_name = 'default'
+ if "intent-generate" in options:
+ intent_generate = True
+ del options["intent-generate"]
+ if "intent-export" in options:
+ del options["intent-export"]
+ if "game-options-preset" in options:
+ preset_name = options["game-options-preset"]
+ del options["game-options-preset"]
+
+ # Properly format YAML output
+ player_name = options["name"]
+ del options["name"]
+
+ description = f"Generated by https://archipelago.gg/ for {game}"
+ if preset_name != 'default' and preset_name != 'custom':
+ description += f" using {preset_name} preset"
+
+ formatted_options = {
+ "name": player_name,
+ "game": game,
+ "description": description,
+ game: options,
+ }
- weighted_settings["baseOptions"]["game"][game_name] = 0
- weighted_settings["games"][game_name] = {}
- weighted_settings["games"][game_name]["gameSettings"] = game_options
- weighted_settings["games"][game_name]["gameItems"] = tuple(world.item_names)
- weighted_settings["games"][game_name]["gameLocations"] = tuple(world.location_names)
+ if intent_generate:
+ return generate_game({player_name: formatted_options})
- with open(os.path.join(target_folder, 'weighted-settings.json'), "w") as f:
- json.dump(weighted_settings, f, indent=2, separators=(',', ': '))
+ else:
+ return send_yaml(player_name, formatted_options)
diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt
index a8b2865aae34..c4267dc2846b 100644
--- a/WebHostLib/requirements.txt
+++ b/WebHostLib/requirements.txt
@@ -1,9 +1,13 @@
-flask>=2.2.3
-pony>=0.7.16; python_version <= '3.10'
-pony @ https://github.com/Berserker66/pony/releases/download/v0.7.16/pony-0.7.16-py3-none-any.whl#0.7.16 ; python_version >= '3.11'
-waitress>=2.1.2
-Flask-Caching>=2.0.2
-Flask-Compress>=1.13
-Flask-Limiter>=3.3.0
-bokeh>=3.1.1
-markupsafe>=2.1.3
+flask>=3.1.1
+werkzeug>=3.1.3
+pony>=0.7.19; python_version <= '3.12'
+pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
+waitress>=3.0.2
+Flask-Caching>=2.3.0
+Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
+Flask-Limiter>=3.12
+bokeh>=3.6.3
+markupsafe>=3.0.2
+setproctitle>=1.3.5
+mistune>=3.1.3
+docutils>=0.22.2
diff --git a/WebHostLib/robots.py b/WebHostLib/robots.py
new file mode 100644
index 000000000000..93c735c71015
--- /dev/null
+++ b/WebHostLib/robots.py
@@ -0,0 +1,15 @@
+from WebHostLib import app
+from flask import abort
+from . import cache
+
+
+@cache.cached()
+@app.route('/robots.txt')
+def robots():
+ # If this host is not official, do not allow search engine crawling
+ if not app.config["ASSET_RIGHTS"]:
+ # filename changed in case the path is intercepted and served by an outside service
+ return app.send_static_file('robots_file.txt')
+
+ # Send 404 if the host has affirmed this to be the official WebHost
+ abort(404)
diff --git a/WebHostLib/session.py b/WebHostLib/session.py
new file mode 100644
index 000000000000..d5dab7d6e6e6
--- /dev/null
+++ b/WebHostLib/session.py
@@ -0,0 +1,31 @@
+from uuid import uuid4, UUID
+
+from flask import session, render_template
+
+from WebHostLib import app
+
+
+@app.before_request
+def register_session():
+ session.permanent = True # technically 31 days after the last visit
+ if not session.get("_id", None):
+ session["_id"] = uuid4() # uniquely identify each session without needing a login
+
+
+@app.route('/session')
+def show_session():
+ return render_template(
+ "session.html",
+ )
+
+
+@app.route('/session/')
+def set_session(_id: str):
+ new_id: UUID = UUID(_id, version=4)
+ old_id: UUID = session["_id"]
+ if old_id != new_id:
+ session["_id"] = new_id
+ return render_template(
+ "session.html",
+ old_id=old_id,
+ )
diff --git a/WebHostLib/static/assets/faq.js b/WebHostLib/static/assets/faq.js
deleted file mode 100644
index 1bf5e5a65995..000000000000
--- a/WebHostLib/static/assets/faq.js
+++ /dev/null
@@ -1,51 +0,0 @@
-window.addEventListener('load', () => {
- const tutorialWrapper = document.getElementById('faq-wrapper');
- new Promise((resolve, reject) => {
- const ajax = new XMLHttpRequest();
- ajax.onreadystatechange = () => {
- if (ajax.readyState !== 4) { return; }
- if (ajax.status === 404) {
- reject("Sorry, the tutorial is not available in that language yet.");
- return;
- }
- if (ajax.status !== 200) {
- reject("Something went wrong while loading the tutorial.");
- return;
- }
- resolve(ajax.responseText);
- };
- ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
- `faq_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
- ajax.send();
- }).then((results) => {
- // Populate page with HTML generated from markdown
- showdown.setOption('tables', true);
- showdown.setOption('strikethrough', true);
- showdown.setOption('literalMidWordUnderscores', true);
- tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
- adjustHeaderWidth();
-
- // Reset the id of all header divs to something nicer
- for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
- const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
- header.setAttribute('id', headerId);
- header.addEventListener('click', () => {
- window.location.hash = `#${headerId}`;
- header.scrollIntoView();
- });
- }
-
- // Manually scroll the user to the appropriate header if anchor navigation is used
- document.fonts.ready.finally(() => {
- if (window.location.hash) {
- const scrollTarget = document.getElementById(window.location.hash.substring(1));
- scrollTarget?.scrollIntoView();
- }
- });
- }).catch((error) => {
- console.error(error);
- tutorialWrapper.innerHTML =
- `This page is out of logic!
- Click here to return to safety. `;
- });
-});
diff --git a/WebHostLib/static/assets/faq/en.md b/WebHostLib/static/assets/faq/en.md
new file mode 100644
index 000000000000..5b42cc6fc6c5
--- /dev/null
+++ b/WebHostLib/static/assets/faq/en.md
@@ -0,0 +1,81 @@
+# Frequently Asked Questions
+
+## What is a randomizer?
+
+A randomizer is a modification of a game which reorganizes the items required to progress through that game. A
+normal play-through might require you to use item A to unlock item B, then C, and so forth. In a randomized
+game, you might first find item C, then A, then B.
+
+This transforms the game from a linear experience into a puzzle, presenting players with a new challenge each time they
+play. Putting items in non-standard locations can require the player to think about the game world and the items they
+encounter in new and interesting ways.
+
+## What is a multiworld?
+
+While a randomizer shuffles a game, a multiworld randomizer shuffles that game for multiple players. For example, in a
+two player multiworld, 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. This creates a cooperative experience, requiring
+players to rely upon each other to complete their game.
+
+## What does multi-game mean?
+
+While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
+players to randomize any of the supported games, and send items between them. This allows players of different
+games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
+Here is a list of our [Supported Games](/games).
+
+## Can I generate a single-player game with Archipelago?
+
+Yes. All of our supported games can be generated as single-player experiences both on the website and by installing
+the Archipelago generator software. The fastest way to do this is on the website. Find the Supported Game you wish to
+play, open the Settings Page, pick your settings, and click Generate Game.
+
+## How do I get started?
+
+We have a [Getting Started](/tutorial/Archipelago/setup/en) guide that will help you get the
+software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
+including multiple games, and hosting multiworlds on the website for ease and convenience.
+
+If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
+our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
+any questions you might have.
+
+## What are some common terms I should know?
+
+As randomizers and multiworld randomizers have been around for a while now, there are quite a few common terms used
+by the communities surrounding them. A list of Archipelago jargon and terms commonly used by the community can be
+found in the [Glossary](/glossary/en).
+
+## Does everyone need to be connected at the same time?
+
+There are two different play-styles that are common for Archipelago multiworld sessions. These sessions can either
+be considered synchronous (or "sync"), where everyone connects and plays at the same time, or asynchronous (or "async"),
+where players connect and play at their own pace. The setup for both is identical. The difference in play-style is how
+you and your friends choose to organize and play your multiworld. Most groups decide on the format before creating
+their multiworld.
+
+If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
+in that game belonging to other players are sent out automatically. This allows other players to continue to play
+uninterrupted. Here is a list of all of our [Server Commands](/tutorial/Archipelago/commands/en).
+
+## What happens if an item is placed somewhere it is impossible to get?
+
+The randomizer has many strict sets of rules it must follow when generating a game. One of the functions of these rules
+is to ensure items necessary to complete the game will be accessible to the player. Many games also have a subset of
+rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
+comfortable exploiting certain glitches in the game.
+
+## I want to develop a game implementation for Archipelago. How do I do that?
+
+The best way to get started is to take a look at our code on GitHub:
+[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
+
+There, you will find examples of games in the `worlds` folder:
+[/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
+
+You may also find developer documentation in the `docs` folder:
+[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
+
+If you have more questions regarding development of a game implementation, feel free to ask in the **#ap-world-dev**
+channel on our Discord.
diff --git a/WebHostLib/static/assets/faq/faq_en.md b/WebHostLib/static/assets/faq/faq_en.md
deleted file mode 100644
index 74f423df1f9f..000000000000
--- a/WebHostLib/static/assets/faq/faq_en.md
+++ /dev/null
@@ -1,69 +0,0 @@
-# Frequently Asked Questions
-
-## What is a randomizer?
-
-A randomizer is a modification of a video game which reorganizes the items required to progress through the game. A
-normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a randomized
-game, you might first find item C, then A, then B.
-
-This transforms games from a linear experience into a puzzle, presenting players with a new challenge each time they
-play a randomized game. Putting items in non-standard locations can require the player to think about the game world and
-the items they encounter in new and interesting ways.
-
-## What happens if an item is placed somewhere it is impossible to get?
-
-The randomizer has many strict sets of rules it must follow when generating a game. One of the functions of these rules
-is to ensure items necessary to complete the game will be accessible to the player. Many games also have a subset of
-rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
-comfortable exploiting certain glitches in the game.
-
-## 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 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.
-
-This creates a cooperative experience during multi-world games, requiring players to rely upon each other to complete
-their game.
-
-## What happens if a person has to leave early?
-
-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?
-
-While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world allows
-players to randomize any of a number of supported games, and send items between them. This allows players of different
-games to interact with one another in a single multiplayer environment.
-
-## Can I generate a single-player game with Archipelago?
-
-Yes. All our supported games can be generated as single-player experiences, and so long as you download the software,
-the website is not required to generate them.
-
-## How do I get started?
-
-If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
-our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
-any questions you might have.
-
-## What are some common terms I should know?
-
-As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
-and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
-to Archipelago and its specific systems please see the [Glossary](/glossary/en).
-
-## I want to add a game to the Archipelago randomizer. How do I do that?
-
-The best way to get started is to take a look at our code on GitHub
-at [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
-
-There you will find examples of games in the worlds folder
-at [/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
-
-You may also find developer documentation in the docs folder
-at [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
-
-If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.
diff --git a/WebHostLib/static/assets/gameInfo.js b/WebHostLib/static/assets/gameInfo.js
deleted file mode 100644
index b8c56905a50a..000000000000
--- a/WebHostLib/static/assets/gameInfo.js
+++ /dev/null
@@ -1,51 +0,0 @@
-window.addEventListener('load', () => {
- const gameInfo = document.getElementById('game-info');
- new Promise((resolve, reject) => {
- const ajax = new XMLHttpRequest();
- ajax.onreadystatechange = () => {
- if (ajax.readyState !== 4) { return; }
- if (ajax.status === 404) {
- reject("Sorry, this game's info page is not available in that language yet.");
- return;
- }
- if (ajax.status !== 200) {
- reject("Something went wrong while loading the info page.");
- return;
- }
- resolve(ajax.responseText);
- };
- ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
- `${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
- ajax.send();
- }).then((results) => {
- // Populate page with HTML generated from markdown
- showdown.setOption('tables', true);
- showdown.setOption('strikethrough', true);
- showdown.setOption('literalMidWordUnderscores', true);
- gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
- adjustHeaderWidth();
-
- // Reset the id of all header divs to something nicer
- for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
- const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
- header.setAttribute('id', headerId);
- header.addEventListener('click', () => {
- window.location.hash = `#${headerId}`;
- header.scrollIntoView();
- });
- }
-
- // Manually scroll the user to the appropriate header if anchor navigation is used
- document.fonts.ready.finally(() => {
- if (window.location.hash) {
- const scrollTarget = document.getElementById(window.location.hash.substring(1));
- scrollTarget?.scrollIntoView();
- }
- });
- }).catch((error) => {
- console.error(error);
- gameInfo.innerHTML =
- `This page is out of logic!
- Click here to return to safety. `;
- });
-});
diff --git a/WebHostLib/static/assets/glossary.js b/WebHostLib/static/assets/glossary.js
deleted file mode 100644
index 04a292008655..000000000000
--- a/WebHostLib/static/assets/glossary.js
+++ /dev/null
@@ -1,51 +0,0 @@
-window.addEventListener('load', () => {
- const tutorialWrapper = document.getElementById('glossary-wrapper');
- new Promise((resolve, reject) => {
- const ajax = new XMLHttpRequest();
- ajax.onreadystatechange = () => {
- if (ajax.readyState !== 4) { return; }
- if (ajax.status === 404) {
- reject("Sorry, the glossary page is not available in that language yet.");
- return;
- }
- if (ajax.status !== 200) {
- reject("Something went wrong while loading the glossary.");
- return;
- }
- resolve(ajax.responseText);
- };
- ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
- `glossary_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
- ajax.send();
- }).then((results) => {
- // Populate page with HTML generated from markdown
- showdown.setOption('tables', true);
- showdown.setOption('strikethrough', true);
- showdown.setOption('literalMidWordUnderscores', true);
- tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
- adjustHeaderWidth();
-
- // Reset the id of all header divs to something nicer
- for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
- const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
- header.setAttribute('id', headerId);
- header.addEventListener('click', () => {
- window.location.hash = `#${headerId}`;
- header.scrollIntoView();
- });
- }
-
- // Manually scroll the user to the appropriate header if anchor navigation is used
- document.fonts.ready.finally(() => {
- if (window.location.hash) {
- const scrollTarget = document.getElementById(window.location.hash.substring(1));
- scrollTarget?.scrollIntoView();
- }
- });
- }).catch((error) => {
- console.error(error);
- tutorialWrapper.innerHTML =
- `This page is out of logic!
- Click here to return to safety. `;
- });
-});
diff --git a/WebHostLib/static/assets/faq/glossary_en.md b/WebHostLib/static/assets/glossary/en.md
similarity index 100%
rename from WebHostLib/static/assets/faq/glossary_en.md
rename to WebHostLib/static/assets/glossary/en.md
diff --git a/WebHostLib/static/assets/hostGame.js b/WebHostLib/static/assets/hostGame.js
index db1ab1ddde3e..01a8da06e5dc 100644
--- a/WebHostLib/static/assets/hostGame.js
+++ b/WebHostLib/static/assets/hostGame.js
@@ -6,6 +6,4 @@ window.addEventListener('load', () => {
document.getElementById('file-input').addEventListener('change', () => {
document.getElementById('host-game-form').submit();
});
-
- adjustFooterHeight();
});
diff --git a/WebHostLib/static/assets/lttp-tracker.js b/WebHostLib/static/assets/lttp-tracker.js
deleted file mode 100644
index 3f01f93cd38c..000000000000
--- a/WebHostLib/static/assets/lttp-tracker.js
+++ /dev/null
@@ -1,20 +0,0 @@
-window.addEventListener('load', () => {
- 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 and location trackers
- document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
- document.getElementById('location-table').innerHTML = fakeDOM.getElementById('location-table').innerHTML;
-
- };
- ajax.open('GET', url);
- ajax.send();
- }, 15000)
-});
diff --git a/WebHostLib/static/assets/minecraftTracker.js b/WebHostLib/static/assets/minecraftTracker.js
deleted file mode 100644
index a698214b8dd6..000000000000
--- a/WebHostLib/static/assets/minecraftTracker.js
+++ /dev/null
@@ -1,49 +0,0 @@
-window.addEventListener('load', () => {
- // Reload tracker every 15 seconds
- const url = window.location;
- setInterval(() => {
- const ajax = new XMLHttpRequest();
- ajax.onreadystatechange = () => {
- if (ajax.readyState !== 4) { return; }
-
- // Create a fake DOM using the returned HTML
- const domParser = new DOMParser();
- const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
-
- // Update item tracker
- document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
- // Update only counters in the location-table
- let counters = document.getElementsByClassName('counter');
- const fakeCounters = fakeDOM.getElementsByClassName('counter');
- for (let i = 0; i < counters.length; i++) {
- counters[i].innerHTML = fakeCounters[i].innerHTML;
- }
- };
- ajax.open('GET', url);
- ajax.send();
- }, 15000)
-
- // Collapsible advancement sections
- const categories = document.getElementsByClassName("location-category");
- for (let i = 0; i < categories.length; i++) {
- let hide_id = categories[i].id.split('-')[0];
- if (hide_id == 'Total') {
- continue;
- }
- categories[i].addEventListener('click', function() {
- // Toggle the advancement list
- document.getElementById(hide_id).classList.toggle("hide");
- // Change text of the header
- const tab_header = document.getElementById(hide_id+'-header').children[0];
- const orig_text = tab_header.innerHTML;
- let new_text;
- if (orig_text.includes("âŧ")) {
- new_text = orig_text.replace("âŧ", "â˛");
- }
- else {
- new_text = orig_text.replace("â˛", "âŧ");
- }
- tab_header.innerHTML = new_text;
- });
- }
-});
diff --git a/WebHostLib/static/assets/player-settings.js b/WebHostLib/static/assets/player-settings.js
deleted file mode 100644
index f75ba9060303..000000000000
--- a/WebHostLib/static/assets/player-settings.js
+++ /dev/null
@@ -1,398 +0,0 @@
-let gameName = null;
-
-window.addEventListener('load', () => {
- gameName = document.getElementById('player-settings').getAttribute('data-game');
-
- // Update game name on page
- document.getElementById('game-name').innerText = gameName;
-
- fetchSettingData().then((results) => {
- let settingHash = localStorage.getItem(`${gameName}-hash`);
- if (!settingHash) {
- // If no hash data has been set before, set it now
- settingHash = md5(JSON.stringify(results));
- localStorage.setItem(`${gameName}-hash`, settingHash);
- localStorage.removeItem(gameName);
- }
-
- if (settingHash !== md5(JSON.stringify(results))) {
- showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " +
- "them all to default.");
- document.getElementById('user-message').addEventListener('click', resetSettings);
- }
-
- // Page setup
- createDefaultSettings(results);
- buildUI(results);
- adjustHeaderWidth();
-
- // Event listeners
- document.getElementById('export-settings').addEventListener('click', () => exportSettings());
- document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
- document.getElementById('generate-game').addEventListener('click', () => generateGame());
-
- // Name input field
- const playerSettings = JSON.parse(localStorage.getItem(gameName));
- const nameInput = document.getElementById('player-name');
- nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
- nameInput.value = playerSettings.name;
- }).catch((e) => {
- console.error(e);
- const url = new URL(window.location.href);
- window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
- })
-});
-
-const resetSettings = () => {
- localStorage.removeItem(gameName);
- localStorage.removeItem(`${gameName}-hash`)
- window.location.reload();
-};
-
-const fetchSettingData = () => new Promise((resolve, reject) => {
- const ajax = new XMLHttpRequest();
- ajax.onreadystatechange = () => {
- if (ajax.readyState !== 4) { return; }
- if (ajax.status !== 200) {
- reject(ajax.responseText);
- return;
- }
- try{ resolve(JSON.parse(ajax.responseText)); }
- catch(error){ reject(error); }
- };
- ajax.open('GET', `${window.location.origin}/static/generated/player-settings/${gameName}.json`, true);
- ajax.send();
-});
-
-const createDefaultSettings = (settingData) => {
- if (!localStorage.getItem(gameName)) {
- const newSettings = {
- [gameName]: {},
- };
- for (let baseOption of Object.keys(settingData.baseOptions)){
- newSettings[baseOption] = settingData.baseOptions[baseOption];
- }
- for (let gameOption of Object.keys(settingData.gameOptions)){
- newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue;
- }
- localStorage.setItem(gameName, JSON.stringify(newSettings));
- }
-};
-
-const buildUI = (settingData) => {
- // Game Options
- const leftGameOpts = {};
- const rightGameOpts = {};
- Object.keys(settingData.gameOptions).forEach((key, index) => {
- if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; }
- else { rightGameOpts[key] = settingData.gameOptions[key]; }
- });
- document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
- document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
-};
-
-const buildOptionsTable = (settings, romOpts = false) => {
- const currentSettings = JSON.parse(localStorage.getItem(gameName));
- const table = document.createElement('table');
- const tbody = document.createElement('tbody');
-
- Object.keys(settings).forEach((setting) => {
- const tr = document.createElement('tr');
-
- // td Left
- const tdl = document.createElement('td');
- const label = document.createElement('label');
- label.textContent = `${settings[setting].displayName}: `;
- label.setAttribute('for', setting);
-
- const questionSpan = document.createElement('span');
- questionSpan.classList.add('interactive');
- questionSpan.setAttribute('data-tooltip', settings[setting].description);
- questionSpan.innerText = '(?)';
-
- label.appendChild(questionSpan);
- tdl.appendChild(label);
- tr.appendChild(tdl);
-
- // td Right
- const tdr = document.createElement('td');
- let element = null;
-
- const randomButton = document.createElement('button');
-
- switch(settings[setting].type){
- case 'select':
- element = document.createElement('div');
- element.classList.add('select-container');
- let select = document.createElement('select');
- select.setAttribute('id', setting);
- select.setAttribute('data-key', setting);
- if (romOpts) { select.setAttribute('data-romOpt', '1'); }
- settings[setting].options.forEach((opt) => {
- const option = document.createElement('option');
- option.setAttribute('value', opt.value);
- option.innerText = opt.name;
- if ((isNaN(currentSettings[gameName][setting]) &&
- (parseInt(opt.value, 10) === parseInt(currentSettings[gameName][setting]))) ||
- (opt.value === currentSettings[gameName][setting]))
- {
- option.selected = true;
- }
- select.appendChild(option);
- });
- select.addEventListener('change', (event) => updateGameSetting(event.target));
- element.appendChild(select);
-
- // Randomize button
- randomButton.innerText = 'đ˛';
- randomButton.classList.add('randomize-button');
- randomButton.setAttribute('data-key', setting);
- randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
- randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
- if (currentSettings[gameName][setting] === 'random') {
- randomButton.classList.add('active');
- select.disabled = true;
- }
-
- element.appendChild(randomButton);
- break;
-
- case 'range':
- element = document.createElement('div');
- element.classList.add('range-container');
-
- let range = document.createElement('input');
- range.setAttribute('type', 'range');
- range.setAttribute('data-key', setting);
- range.setAttribute('min', settings[setting].min);
- range.setAttribute('max', settings[setting].max);
- range.value = currentSettings[gameName][setting];
- range.addEventListener('change', (event) => {
- document.getElementById(`${setting}-value`).innerText = event.target.value;
- updateGameSetting(event.target);
- });
- element.appendChild(range);
-
- let rangeVal = document.createElement('span');
- rangeVal.classList.add('range-value');
- rangeVal.setAttribute('id', `${setting}-value`);
- rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
- currentSettings[gameName][setting] : settings[setting].defaultValue;
- element.appendChild(rangeVal);
-
- // Randomize button
- randomButton.innerText = 'đ˛';
- randomButton.classList.add('randomize-button');
- randomButton.setAttribute('data-key', setting);
- randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
- randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
- if (currentSettings[gameName][setting] === 'random') {
- randomButton.classList.add('active');
- range.disabled = true;
- }
-
- element.appendChild(randomButton);
- break;
-
- case 'special_range':
- element = document.createElement('div');
- element.classList.add('special-range-container');
-
- // Build the select element
- let specialRangeSelect = document.createElement('select');
- specialRangeSelect.setAttribute('data-key', setting);
- Object.keys(settings[setting].value_names).forEach((presetName) => {
- 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');
- customOption.innerText = 'Custom';
- customOption.value = 'custom';
- customOption.selected = true;
- specialRangeSelect.appendChild(customOption);
- if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
- specialRangeSelect.value = Number(currentSettings[gameName][setting]);
- }
-
- // Build range element
- let specialRangeWrapper = document.createElement('div');
- specialRangeWrapper.classList.add('special-range-wrapper');
- let specialRange = document.createElement('input');
- specialRange.setAttribute('type', 'range');
- specialRange.setAttribute('data-key', setting);
- specialRange.setAttribute('min', settings[setting].min);
- specialRange.setAttribute('max', settings[setting].max);
- specialRange.value = currentSettings[gameName][setting];
-
- // Build rage value element
- let specialRangeVal = document.createElement('span');
- specialRangeVal.classList.add('range-value');
- specialRangeVal.setAttribute('id', `${setting}-value`);
- specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
- currentSettings[gameName][setting] : settings[setting].defaultValue;
-
- // Configure select event listener
- specialRangeSelect.addEventListener('change', (event) => {
- if (event.target.value === 'custom') { return; }
-
- // Update range slider
- specialRange.value = event.target.value;
- document.getElementById(`${setting}-value`).innerText = event.target.value;
- updateGameSetting(event.target);
- });
-
- // Configure range event handler
- specialRange.addEventListener('change', (event) => {
- // Update select element
- specialRangeSelect.value =
- (Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
- parseInt(event.target.value) : 'custom';
- document.getElementById(`${setting}-value`).innerText = event.target.value;
- updateGameSetting(event.target);
- });
-
- element.appendChild(specialRangeSelect);
- specialRangeWrapper.appendChild(specialRange);
- specialRangeWrapper.appendChild(specialRangeVal);
- element.appendChild(specialRangeWrapper);
-
- // Randomize button
- randomButton.innerText = 'đ˛';
- randomButton.classList.add('randomize-button');
- randomButton.setAttribute('data-key', setting);
- randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
- randomButton.addEventListener('click', (event) => toggleRandomize(
- event, specialRange, specialRangeSelect)
- );
- if (currentSettings[gameName][setting] === 'random') {
- randomButton.classList.add('active');
- specialRange.disabled = true;
- specialRangeSelect.disabled = true;
- }
-
- specialRangeWrapper.appendChild(randomButton);
- break;
-
- default:
- console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
- return;
- }
-
- tdr.appendChild(element);
- tr.appendChild(tdr);
- tbody.appendChild(tr);
- });
-
- table.appendChild(tbody);
- return table;
-};
-
-const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
- const active = event.target.classList.contains('active');
- const randomButton = event.target;
-
- if (active) {
- randomButton.classList.remove('active');
- inputElement.disabled = undefined;
- if (optionalSelectElement) {
- optionalSelectElement.disabled = undefined;
- }
- } else {
- randomButton.classList.add('active');
- inputElement.disabled = true;
- if (optionalSelectElement) {
- optionalSelectElement.disabled = true;
- }
- }
-
- updateGameSetting(randomButton);
-};
-
-const updateBaseSetting = (event) => {
- const options = JSON.parse(localStorage.getItem(gameName));
- options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
- event.target.value : parseInt(event.target.value);
- localStorage.setItem(gameName, JSON.stringify(options));
-};
-
-const updateGameSetting = (settingElement) => {
- const options = JSON.parse(localStorage.getItem(gameName));
-
- if (settingElement.classList.contains('randomize-button')) {
- // If the event passed in is the randomize button, then we know what we must do.
- options[gameName][settingElement.getAttribute('data-key')] = 'random';
- } else {
- options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ?
- settingElement.value : parseInt(settingElement.value, 10);
- }
-
- localStorage.setItem(gameName, JSON.stringify(options));
-};
-
-const exportSettings = () => {
- const settings = JSON.parse(localStorage.getItem(gameName));
- if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
- return showUserMessage('You must enter a player name!');
- }
- const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
- download(`${document.getElementById('player-name').value}.yaml`, yamlText);
-};
-
-/** Create an anchor and trigger a download of a text file. */
-const download = (filename, text) => {
- const downloadLink = document.createElement('a');
- downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
- downloadLink.setAttribute('download', filename);
- downloadLink.style.display = 'none';
- document.body.appendChild(downloadLink);
- downloadLink.click();
- document.body.removeChild(downloadLink);
-};
-
-const generateGame = (raceMode = false) => {
- const settings = JSON.parse(localStorage.getItem(gameName));
- if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
- return showUserMessage('You must enter a player name!');
- }
-
- axios.post('/api/generate', {
- weights: { player: settings },
- presetData: { player: settings },
- playerCount: 1,
- spoiler: 3,
- race: raceMode ? '1' : '0',
- }).then((response) => {
- window.location.href = response.data.url;
- }).catch((error) => {
- let userMessage = 'Something went wrong and your game could not be generated.';
- if (error.response.data.text) {
- userMessage += ' ' + error.response.data.text;
- }
- showUserMessage(userMessage);
- console.error(error);
- });
-};
-
-const showUserMessage = (message) => {
- const userMessage = document.getElementById('user-message');
- userMessage.innerText = message;
- userMessage.classList.add('visible');
- window.scrollTo(0, 0);
- userMessage.addEventListener('click', () => {
- userMessage.classList.remove('visible');
- userMessage.addEventListener('click', hideUserMessage);
- });
-};
-
-const hideUserMessage = () => {
- const userMessage = document.getElementById('user-message');
- userMessage.classList.remove('visible');
- userMessage.removeEventListener('click', hideUserMessage);
-};
diff --git a/WebHostLib/static/assets/playerOptions.js b/WebHostLib/static/assets/playerOptions.js
new file mode 100644
index 000000000000..fbf96a3a71c2
--- /dev/null
+++ b/WebHostLib/static/assets/playerOptions.js
@@ -0,0 +1,340 @@
+let presets = {};
+
+window.addEventListener('load', async () => {
+ // Load settings from localStorage, if available
+ loadSettings();
+
+ // Fetch presets if available
+ await fetchPresets();
+
+ // Handle changes to range inputs
+ document.querySelectorAll('input[type=range]').forEach((range) => {
+ const optionName = range.getAttribute('id');
+ range.addEventListener('change', () => {
+ document.getElementById(`${optionName}-value`).innerText = range.value;
+
+ // Handle updating named range selects to "custom" if appropriate
+ const select = document.querySelector(`select[data-option-name=${optionName}]`);
+ if (select) {
+ let updated = false;
+ select?.childNodes.forEach((option) => {
+ if (option.value === range.value) {
+ select.value = range.value;
+ updated = true;
+ }
+ });
+ if (!updated) {
+ select.value = 'custom';
+ }
+ }
+ });
+ });
+
+ // Handle changes to named range selects
+ document.querySelectorAll('.named-range-container select').forEach((select) => {
+ const optionName = select.getAttribute('data-option-name');
+ select.addEventListener('change', (evt) => {
+ document.getElementById(optionName).value = evt.target.value;
+ document.getElementById(`${optionName}-value`).innerText = evt.target.value;
+ });
+ });
+
+ // Handle changes to randomize checkboxes
+ document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
+ const optionName = checkbox.getAttribute('data-option-name');
+ checkbox.addEventListener('change', () => {
+ const optionInput = document.getElementById(optionName);
+ const namedRangeSelect = document.querySelector(`select[data-option-name=${optionName}]`);
+ const customInput = document.getElementById(`${optionName}-custom`);
+ if (checkbox.checked) {
+ optionInput.setAttribute('disabled', '1');
+ namedRangeSelect?.setAttribute('disabled', '1');
+ if (customInput) {
+ customInput.setAttribute('disabled', '1');
+ }
+ } else {
+ optionInput.removeAttribute('disabled');
+ namedRangeSelect?.removeAttribute('disabled');
+ if (customInput) {
+ customInput.removeAttribute('disabled');
+ }
+ }
+ });
+ });
+
+ // Handle changes to TextChoice input[type=text]
+ document.querySelectorAll('.text-choice-container input[type=text]').forEach((input) => {
+ const optionName = input.getAttribute('data-option-name');
+ input.addEventListener('input', () => {
+ const select = document.getElementById(optionName);
+ const optionValues = [];
+ select.childNodes.forEach((option) => optionValues.push(option.value));
+ select.value = (optionValues.includes(input.value)) ? input.value : 'custom';
+ });
+ });
+
+ // Handle changes to TextChoice select
+ document.querySelectorAll('.text-choice-container select').forEach((select) => {
+ const optionName = select.getAttribute('id');
+ select.addEventListener('change', () => {
+ document.getElementById(`${optionName}-custom`).value = '';
+ });
+ });
+
+ // Update the "Option Preset" select to read "custom" when changes are made to relevant inputs
+ const presetSelect = document.getElementById('game-options-preset');
+ document.querySelectorAll('input, select').forEach((input) => {
+ if ( // Ignore inputs which have no effect on yaml generation
+ (input.id === 'player-name') ||
+ (input.id === 'game-options-preset') ||
+ (input.classList.contains('group-toggle')) ||
+ (input.type === 'submit')
+ ) {
+ return;
+ }
+ input.addEventListener('change', () => {
+ presetSelect.value = 'custom';
+ });
+ });
+
+ // Handle changes to presets select
+ document.getElementById('game-options-preset').addEventListener('change', choosePreset);
+
+ // Save settings to localStorage when form is submitted
+ document.getElementById('options-form').addEventListener('submit', (evt) => {
+ const playerName = document.getElementById('player-name');
+ if (!playerName.value.trim()) {
+ evt.preventDefault();
+ window.scrollTo(0, 0);
+ showUserMessage('You must enter a player name!');
+ }
+
+ saveSettings();
+ });
+});
+
+// Save all settings to localStorage
+const saveSettings = () => {
+ const options = {
+ inputs: {},
+ checkboxes: {},
+ };
+ document.querySelectorAll('input, select').forEach((input) => {
+ if (input.type === 'submit') {
+ // Ignore submit inputs
+ }
+ else if (input.type === 'checkbox') {
+ options.checkboxes[input.id] = input.checked;
+ }
+ else {
+ options.inputs[input.id] = input.value
+ }
+ });
+ const game = document.getElementById('player-options').getAttribute('data-game');
+ localStorage.setItem(game, JSON.stringify(options));
+};
+
+// Load all options from localStorage
+const loadSettings = () => {
+ const game = document.getElementById('player-options').getAttribute('data-game');
+
+ const options = JSON.parse(localStorage.getItem(game));
+ if (options) {
+ if (!options.inputs || !options.checkboxes) {
+ localStorage.removeItem(game);
+ return;
+ }
+
+ // Restore value-based inputs and selects
+ Object.keys(options.inputs).forEach((key) => {
+ try{
+ document.getElementById(key).value = options.inputs[key];
+ const rangeValue = document.getElementById(`${key}-value`);
+ if (rangeValue) {
+ rangeValue.innerText = options.inputs[key];
+ }
+ } catch (err) {
+ console.error(`Unable to restore value to input with id ${key}`);
+ }
+ });
+
+ // Restore checkboxes
+ Object.keys(options.checkboxes).forEach((key) => {
+ try{
+ if (options.checkboxes[key]) {
+ document.getElementById(key).setAttribute('checked', '1');
+ }
+ } catch (err) {
+ console.error(`Unable to restore value to input with id ${key}`);
+ }
+ });
+ }
+
+ // Ensure any input for which the randomize checkbox is checked by default, the relevant inputs are disabled
+ document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
+ const optionName = checkbox.getAttribute('data-option-name');
+ if (checkbox.checked) {
+ const input = document.getElementById(optionName);
+ if (input) {
+ input.setAttribute('disabled', '1');
+ }
+ const customInput = document.getElementById(`${optionName}-custom`);
+ if (customInput) {
+ customInput.setAttribute('disabled', '1');
+ }
+ }
+ });
+};
+
+/**
+ * Fetch the preset data for this game and apply the presets if localStorage indicates one was previously chosen
+ * @returns {Promise}
+ */
+const fetchPresets = async () => {
+ const response = await fetch('option-presets');
+ presets = await response.json();
+ const presetSelect = document.getElementById('game-options-preset');
+ presetSelect.removeAttribute('disabled');
+
+ const game = document.getElementById('player-options').getAttribute('data-game');
+ const presetToApply = localStorage.getItem(`${game}-preset`);
+ const playerName = localStorage.getItem(`${game}-player`);
+ if (presetToApply) {
+ localStorage.removeItem(`${game}-preset`);
+ presetSelect.value = presetToApply;
+ applyPresets(presetToApply);
+ }
+
+ if (playerName) {
+ document.getElementById('player-name').value = playerName;
+ localStorage.removeItem(`${game}-player`);
+ }
+};
+
+/**
+ * Clear the localStorage for this game and set a preset to be loaded upon page reload
+ * @param evt
+ */
+const choosePreset = (evt) => {
+ if (evt.target.value === 'custom') { return; }
+
+ const game = document.getElementById('player-options').getAttribute('data-game');
+ localStorage.removeItem(game);
+
+ localStorage.setItem(`${game}-player`, document.getElementById('player-name').value);
+ if (evt.target.value !== 'default') {
+ localStorage.setItem(`${game}-preset`, evt.target.value);
+ }
+
+ document.querySelectorAll('#options-form input, #options-form select').forEach((input) => {
+ if (input.id === 'player-name') { return; }
+ input.removeAttribute('value');
+ });
+
+ window.location.replace(window.location.href);
+};
+
+const applyPresets = (presetName) => {
+ // Ignore the "default" preset, because it gets set automatically by Jinja
+ if (presetName === 'default') {
+ saveSettings();
+ return;
+ }
+
+ if (!presets[presetName]) {
+ console.error(`Unknown preset ${presetName} chosen`);
+ return;
+ }
+
+ const preset = presets[presetName];
+ Object.keys(preset).forEach((optionName) => {
+ const optionValue = preset[optionName];
+
+ // Handle List and Set options
+ if (Array.isArray(optionValue)) {
+ document.querySelectorAll(`input[type=checkbox][name=${optionName}]`).forEach((checkbox) => {
+ if (optionValue.includes(checkbox.value)) {
+ checkbox.setAttribute('checked', '1');
+ } else {
+ checkbox.removeAttribute('checked');
+ }
+ });
+ return;
+ }
+
+ // Handle Dict options
+ if (typeof(optionValue) === 'object' && optionValue !== null) {
+ const itemNames = Object.keys(optionValue);
+ document.querySelectorAll(`input[type=number][data-option-name=${optionName}]`).forEach((input) => {
+ const itemName = input.getAttribute('data-item-name');
+ input.value = (itemNames.includes(itemName)) ? optionValue[itemName] : 0
+ });
+ return;
+ }
+
+ // Identify all possible elements
+ const normalInput = document.getElementById(optionName);
+ const customInput = document.getElementById(`${optionName}-custom`);
+ const rangeValue = document.getElementById(`${optionName}-value`);
+ const randomizeInput = document.getElementById(`random-${optionName}`);
+ const namedRangeSelect = document.getElementById(`${optionName}-select`);
+
+ // It is possible for named ranges to use name of a value rather than the value itself. This is accounted for here
+ let trueValue = optionValue;
+ if (namedRangeSelect) {
+ namedRangeSelect.querySelectorAll('option').forEach((opt) => {
+ if (opt.innerText.startsWith(optionValue)) {
+ trueValue = opt.value;
+ }
+ });
+ namedRangeSelect.value = trueValue;
+ // It is also possible for a preset to use an unnamed value. If this happens, set the dropdown to "Custom"
+ if (namedRangeSelect.selectedIndex == -1)
+ {
+ namedRangeSelect.value = "custom";
+ }
+ }
+
+ // Handle options whose presets are "random"
+ if (optionValue === 'random') {
+ normalInput.setAttribute('disabled', '1');
+ randomizeInput.setAttribute('checked', '1');
+ if (customInput) {
+ customInput.setAttribute('disabled', '1');
+ }
+ if (rangeValue) {
+ rangeValue.innerText = normalInput.value;
+ }
+ if (namedRangeSelect) {
+ namedRangeSelect.setAttribute('disabled', '1');
+ }
+ return;
+ }
+
+ // Handle normal (text, number, select, etc.) and custom inputs (custom inputs exist with TextChoice only)
+ normalInput.value = trueValue;
+ normalInput.removeAttribute('disabled');
+ randomizeInput.removeAttribute('checked');
+ if (customInput) {
+ document.getElementById(`${optionName}-custom`).removeAttribute('disabled');
+ }
+ if (rangeValue) {
+ rangeValue.innerText = trueValue;
+ }
+ });
+
+ saveSettings();
+};
+
+const showUserMessage = (text) => {
+ const userMessage = document.getElementById('user-message');
+ userMessage.innerText = text;
+ userMessage.addEventListener('click', hideUserMessage);
+ userMessage.style.display = 'block';
+};
+
+const hideUserMessage = () => {
+ const userMessage = document.getElementById('user-message');
+ userMessage.removeEventListener('click', hideUserMessage);
+ userMessage.style.display = 'none';
+};
diff --git a/WebHostLib/static/assets/sc2Tracker.js b/WebHostLib/static/assets/sc2Tracker.js
new file mode 100644
index 000000000000..19cff21c0fa2
--- /dev/null
+++ b/WebHostLib/static/assets/sc2Tracker.js
@@ -0,0 +1,43 @@
+let updateSection = (sectionName, fakeDOM) => {
+ document.getElementById(sectionName).innerHTML = fakeDOM.getElementById(sectionName).innerHTML;
+}
+
+window.addEventListener('load', () => {
+ // Reload tracker every 60 seconds (sync'd)
+ const url = window.location;
+ // Note: This synchronization code is adapted from code in trackerCommon.js
+ const targetSecond = parseInt(document.getElementById('player-tracker').getAttribute('data-second')) + 3;
+ console.log("Target second of refresh: " + targetSecond);
+
+ let getSleepTimeSeconds = () => {
+ // -40 % 60 is -40, which is absolutely wrong and should burn
+ var sleepSeconds = (((targetSecond - new Date().getSeconds()) % 60) + 60) % 60;
+ return sleepSeconds || 60;
+ };
+
+ let updateTracker = () => {
+ const ajax = new XMLHttpRequest();
+ ajax.onreadystatechange = () => {
+ if (ajax.readyState !== 4) { return; }
+
+ // Create a fake DOM using the returned HTML
+ const domParser = new DOMParser();
+ const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
+
+ // Update dynamic sections
+ updateSection('player-info', fakeDOM);
+ updateSection('section-filler', fakeDOM);
+ updateSection('section-terran', fakeDOM);
+ updateSection('section-zerg', fakeDOM);
+ updateSection('section-protoss', fakeDOM);
+ updateSection('section-nova', fakeDOM);
+ updateSection('section-kerrigan', fakeDOM);
+ updateSection('section-keys', fakeDOM);
+ updateSection('section-locations', fakeDOM);
+ };
+ ajax.open('GET', url);
+ ajax.send();
+ updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
+ };
+ window.updater = setTimeout(updateTracker, getSleepTimeSeconds() * 1000);
+});
diff --git a/WebHostLib/static/assets/sc2wolTracker.js b/WebHostLib/static/assets/sc2wolTracker.js
deleted file mode 100644
index a698214b8dd6..000000000000
--- a/WebHostLib/static/assets/sc2wolTracker.js
+++ /dev/null
@@ -1,49 +0,0 @@
-window.addEventListener('load', () => {
- // Reload tracker every 15 seconds
- const url = window.location;
- setInterval(() => {
- const ajax = new XMLHttpRequest();
- ajax.onreadystatechange = () => {
- if (ajax.readyState !== 4) { return; }
-
- // Create a fake DOM using the returned HTML
- const domParser = new DOMParser();
- const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
-
- // Update item tracker
- document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
- // Update only counters in the location-table
- let counters = document.getElementsByClassName('counter');
- const fakeCounters = fakeDOM.getElementsByClassName('counter');
- for (let i = 0; i < counters.length; i++) {
- counters[i].innerHTML = fakeCounters[i].innerHTML;
- }
- };
- ajax.open('GET', url);
- ajax.send();
- }, 15000)
-
- // Collapsible advancement sections
- const categories = document.getElementsByClassName("location-category");
- for (let i = 0; i < categories.length; i++) {
- let hide_id = categories[i].id.split('-')[0];
- if (hide_id == 'Total') {
- continue;
- }
- categories[i].addEventListener('click', function() {
- // Toggle the advancement list
- document.getElementById(hide_id).classList.toggle("hide");
- // Change text of the header
- const tab_header = document.getElementById(hide_id+'-header').children[0];
- const orig_text = tab_header.innerHTML;
- let new_text;
- if (orig_text.includes("âŧ")) {
- new_text = orig_text.replace("âŧ", "â˛");
- }
- else {
- new_text = orig_text.replace("â˛", "âŧ");
- }
- tab_header.innerHTML = new_text;
- });
- }
-});
diff --git a/WebHostLib/static/assets/styleController.js b/WebHostLib/static/assets/styleController.js
deleted file mode 100644
index 924e86ee2674..000000000000
--- a/WebHostLib/static/assets/styleController.js
+++ /dev/null
@@ -1,47 +0,0 @@
-const adjustFooterHeight = () => {
- // If there is no footer on this page, do nothing
- const footer = document.getElementById('island-footer');
- if (!footer) { return; }
-
- // If the body is taller than the window, also do nothing
- if (document.body.offsetHeight > window.innerHeight) {
- footer.style.marginTop = '0';
- return;
- }
-
- // Add a margin-top to the footer to position it at the bottom of the screen
- const sibling = footer.previousElementSibling;
- const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight);
- if (margin < 1) {
- footer.style.marginTop = '0';
- return;
- }
- footer.style.marginTop = `${margin}px`;
-};
-
-const adjustHeaderWidth = () => {
- // If there is no header, do nothing
- const header = document.getElementById('base-header');
- if (!header) { return; }
-
- const tempDiv = document.createElement('div');
- tempDiv.style.width = '100px';
- tempDiv.style.height = '100px';
- tempDiv.style.overflow = 'scroll';
- tempDiv.style.position = 'absolute';
- tempDiv.style.top = '-500px';
- document.body.appendChild(tempDiv);
- const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth;
- document.body.removeChild(tempDiv);
-
- const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement;
- const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0;
- document.getElementById('base-header-right').style.marginRight = `${margin}px`;
-};
-
-window.addEventListener('load', () => {
- window.addEventListener('resize', adjustFooterHeight);
- window.addEventListener('resize', adjustHeaderWidth);
- adjustFooterHeight();
- adjustHeaderWidth();
-});
diff --git a/WebHostLib/static/assets/supportedGames.js b/WebHostLib/static/assets/supportedGames.js
new file mode 100644
index 000000000000..b692db9283d2
--- /dev/null
+++ b/WebHostLib/static/assets/supportedGames.js
@@ -0,0 +1,44 @@
+window.addEventListener('load', () => {
+ // Add toggle listener to all elements with .collapse-toggle
+ const toggleButtons = document.querySelectorAll('details');
+
+ // Handle game filter input
+ const gameSearch = document.getElementById('game-search');
+ gameSearch.value = '';
+ gameSearch.addEventListener('input', (evt) => {
+ if (!evt.target.value.trim()) {
+ // If input is empty, display all games as collapsed
+ return toggleButtons.forEach((header) => {
+ header.style.display = null;
+ header.removeAttribute('open');
+ });
+ }
+
+ // Loop over all the games
+ toggleButtons.forEach((header) => {
+ // If the game name includes the search string, display the game. If not, hide it
+ if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) {
+ header.style.display = null;
+ header.setAttribute('open', '1');
+ } else {
+ header.style.display = 'none';
+ header.removeAttribute('open');
+ }
+ });
+ });
+
+ document.getElementById('expand-all').addEventListener('click', expandAll);
+ document.getElementById('collapse-all').addEventListener('click', collapseAll);
+});
+
+const expandAll = () => {
+ document.querySelectorAll('details').forEach((detail) => {
+ detail.setAttribute('open', '1');
+ });
+};
+
+const collapseAll = () => {
+ document.querySelectorAll('details').forEach((detail) => {
+ detail.removeAttribute('open');
+ });
+};
diff --git a/WebHostLib/static/assets/trackerCommon.js b/WebHostLib/static/assets/trackerCommon.js
index c08590cbf7db..6324837b2816 100644
--- a/WebHostLib/static/assets/trackerCommon.js
+++ b/WebHostLib/static/assets/trackerCommon.js
@@ -4,47 +4,72 @@ const adjustTableHeight = () => {
return;
const upperDistance = tablesContainer.getBoundingClientRect().top;
- const containerHeight = window.innerHeight - upperDistance;
- tablesContainer.style.maxHeight = `calc(${containerHeight}px - 1rem)`;
-
const tableWrappers = document.getElementsByClassName('table-wrapper');
- for(let i=0; i < tableWrappers.length; i++){
- const maxHeight = (window.innerHeight - upperDistance) / 2;
- tableWrappers[i].style.maxHeight = `calc(${maxHeight}px - 1rem)`;
+ for (let i = 0; i < tableWrappers.length; i++) {
+ // Ensure we are starting from maximum size prior to calculation.
+ tableWrappers[i].style.height = null;
+ tableWrappers[i].style.maxHeight = null;
+
+ // Set as a reasonable height, but still allows the user to resize element if they desire.
+ const currentHeight = tableWrappers[i].offsetHeight;
+ const maxHeight = (window.innerHeight - upperDistance) / Math.min(tableWrappers.length, 4);
+ if (currentHeight > maxHeight) {
+ tableWrappers[i].style.height = `calc(${maxHeight}px - 1rem)`;
+ }
+
+ tableWrappers[i].style.maxHeight = `${currentHeight}px`;
}
};
+/**
+ * Convert an integer number of seconds into a human readable HH:MM format
+ * @param {Number} seconds
+ * @returns {string}
+ */
+const secondsToHours = (seconds) => {
+ let hours = Math.floor(seconds / 3600);
+ let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0');
+ return `${hours}:${minutes}`;
+};
+
window.addEventListener('load', () => {
const tables = $(".table").DataTable({
paging: false,
info: false,
dom: "t",
stateSave: true,
- stateSaveCallback: function(settings, data) {
+ stateSaveCallback: function (settings, data) {
delete data.search;
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
},
- stateLoadCallback: function(settings) {
+ stateLoadCallback: function (settings) {
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
},
+ footerCallback: function (tfoot, data, start, end, display) {
+ if (tfoot) {
+ const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x));
+ Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText =
+ (activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None';
+ }
+ },
columnDefs: [
+ {
+ targets: 'last-activity',
+ name: 'lastActivity'
+ },
{
targets: 'hours',
render: function (data, type, row) {
if (type === "sort" || type === 'type') {
if (data === "None")
- return -1;
+ return Number.MAX_VALUE;
return parseInt(data);
}
if (data === "None")
return data;
- let hours = Math.floor(data / 3600);
- let minutes = Math.floor((data - (hours * 3600)) / 60);
-
- if (minutes < 10) {minutes = "0"+minutes;}
- return hours+':'+minutes;
+ return secondsToHours(data);
}
},
{
@@ -98,44 +123,64 @@ window.addEventListener('load', () => {
event.preventDefault();
}
});
- const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
- const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
+ const target_second = parseInt(document.getElementById('tracker-wrapper').getAttribute('data-second')) + 3;
+ console.log("Target second of refresh: " + target_second);
- function getSleepTimeSeconds(){
+ 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;
}
+ let update_on_view = false;
const update = () => {
- const target = $("
");
- 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");
- const old_table = tables.eq(i);
- const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
- const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
- old_table.clear();
- old_table.rows.add(new_trs).draw();
- $(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
- $(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
- });
- $("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
- } else {
- console.log("Failed to connect to Server, in order to update Table Data.");
- console.log(response);
- }
- })
- setTimeout(update, getSleepTimeSeconds()*1000);
+ if (document.hidden) {
+ console.log("Document reporting as not visible, not updating Tracker...");
+ update_on_view = true;
+ } else {
+ update_on_view = false;
+ const target = $("
");
+ 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");
+ const footer_tr = $(new_table).find("tfoot>tr");
+ const old_table = tables.eq(i);
+ const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop();
+ const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft();
+ old_table.clear();
+ if (footer_tr.length) {
+ $(old_table.table).find("tfoot").html(footer_tr);
+ }
+ old_table.rows.add(new_trs);
+ old_table.draw();
+ $(old_table.settings()[0].nScrollBody).scrollTop(topscroll);
+ $(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll);
+ });
+ $("#multi-stream-link").replaceWith(target.find("#multi-stream-link"));
+ } else {
+ console.log("Failed to connect to Server, in order to update Table Data.");
+ console.log(response);
+ }
+ })
+ }
+ updater = setTimeout(update, getSleepTimeSeconds() * 1000);
}
- setTimeout(update, getSleepTimeSeconds()*1000);
+ let updater = setTimeout(update, getSleepTimeSeconds() * 1000);
window.addEventListener('resize', () => {
adjustTableHeight();
tables.draw();
});
+ window.addEventListener('visibilitychange', () => {
+ if (!document.hidden && update_on_view) {
+ console.log("Page became visible, tracker should be refreshed.");
+ clearTimeout(updater);
+ update();
+ }
+ });
+
adjustTableHeight();
});
diff --git a/WebHostLib/static/assets/tutorial.js b/WebHostLib/static/assets/tutorial.js
deleted file mode 100644
index 1db08d85b352..000000000000
--- a/WebHostLib/static/assets/tutorial.js
+++ /dev/null
@@ -1,58 +0,0 @@
-window.addEventListener('load', () => {
- const tutorialWrapper = document.getElementById('tutorial-wrapper');
- new Promise((resolve, reject) => {
- const ajax = new XMLHttpRequest();
- ajax.onreadystatechange = () => {
- if (ajax.readyState !== 4) { return; }
- if (ajax.status === 404) {
- reject("Sorry, the tutorial is not available in that language yet.");
- return;
- }
- if (ajax.status !== 200) {
- reject("Something went wrong while loading the tutorial.");
- return;
- }
- resolve(ajax.responseText);
- };
- ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
- `${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
- `${tutorialWrapper.getAttribute('data-lang')}.md`, true);
- ajax.send();
- }).then((results) => {
- // Populate page with HTML generated from markdown
- showdown.setOption('tables', true);
- showdown.setOption('strikethrough', true);
- showdown.setOption('literalMidWordUnderscores', true);
- showdown.setOption('disableForced4SpacesIndentedSublists', true);
- tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
- adjustHeaderWidth();
-
- const title = document.querySelector('h1')
- if (title) {
- document.title = title.textContent;
- }
-
- // Reset the id of all header divs to something nicer
- for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
- const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
- header.setAttribute('id', headerId);
- header.addEventListener('click', () => {
- window.location.hash = `#${headerId}`;
- header.scrollIntoView();
- });
- }
-
- // Manually scroll the user to the appropriate header if anchor navigation is used
- document.fonts.ready.finally(() => {
- if (window.location.hash) {
- const scrollTarget = document.getElementById(window.location.hash.substring(1));
- scrollTarget?.scrollIntoView();
- }
- });
- }).catch((error) => {
- console.error(error);
- tutorialWrapper.innerHTML =
- `This page is out of logic!
- Click here to return to safety. `;
- });
-});
diff --git a/WebHostLib/static/assets/tutorialLanding.js b/WebHostLib/static/assets/tutorialLanding.js
deleted file mode 100644
index b820cc34653a..000000000000
--- a/WebHostLib/static/assets/tutorialLanding.js
+++ /dev/null
@@ -1,81 +0,0 @@
-const showError = () => {
- const tutorial = document.getElementById('tutorial-landing');
- document.getElementById('page-title').innerText = 'This page is out of logic!';
- tutorial.removeChild(document.getElementById('loading'));
- const userMessage = document.createElement('h3');
- const homepageLink = document.createElement('a');
- homepageLink.innerText = 'Click here';
- homepageLink.setAttribute('href', '/');
- userMessage.append(homepageLink);
- userMessage.append(' to go back to safety!');
- tutorial.append(userMessage);
-};
-
-window.addEventListener('load', () => {
- const ajax = new XMLHttpRequest();
- ajax.onreadystatechange = () => {
- if (ajax.readyState !== 4) { return; }
- const tutorialDiv = document.getElementById('tutorial-landing');
- if (ajax.status !== 200) { return showError(); }
-
- try {
- const games = JSON.parse(ajax.responseText);
- games.forEach((game) => {
- const gameTitle = document.createElement('h2');
- gameTitle.innerText = game.gameTitle;
- gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
- tutorialDiv.appendChild(gameTitle);
-
- game.tutorials.forEach((tutorial) => {
- const tutorialName = document.createElement('h3');
- tutorialName.innerText = tutorial.name;
- tutorialDiv.appendChild(tutorialName);
-
- const tutorialDescription = document.createElement('p');
- tutorialDescription.innerText = tutorial.description;
- tutorialDiv.appendChild(tutorialDescription);
-
- const intro = document.createElement('p');
- intro.innerText = 'This guide is available in the following languages:';
- tutorialDiv.appendChild(intro);
-
- const fileList = document.createElement('ul');
- tutorial.files.forEach((file) => {
- const listItem = document.createElement('li');
- const anchor = document.createElement('a');
- anchor.innerText = file.language;
- anchor.setAttribute('href', `${window.location.origin}/tutorial/${file.link}`);
- listItem.appendChild(anchor);
-
- listItem.append(' by ');
- for (let author of file.authors) {
- listItem.append(author);
- if (file.authors.indexOf(author) !== (file.authors.length -1)) {
- listItem.append(', ');
- }
- }
-
- fileList.appendChild(listItem);
- });
- tutorialDiv.appendChild(fileList);
- });
- });
-
- tutorialDiv.removeChild(document.getElementById('loading'));
- } catch (error) {
- showError();
- console.error(error);
- }
-
- // Check if we are on an anchor when coming in, and scroll to it.
- const hash = window.location.hash;
- if (hash) {
- const offset = 128; // To account for navbar banner at top of page.
- window.scrollTo(0, 0);
- const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
- window.scrollTo(rect.left, rect.top - offset);
- }
- };
- ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
- ajax.send();
-});
diff --git a/WebHostLib/static/assets/weighted-settings.js b/WebHostLib/static/assets/weighted-settings.js
deleted file mode 100644
index 6e86d470f05c..000000000000
--- a/WebHostLib/static/assets/weighted-settings.js
+++ /dev/null
@@ -1,1219 +0,0 @@
-window.addEventListener('load', () => {
- fetchSettingData().then((results) => {
- let settingHash = localStorage.getItem('weighted-settings-hash');
- if (!settingHash) {
- // If no hash data has been set before, set it now
- settingHash = md5(JSON.stringify(results));
- localStorage.setItem('weighted-settings-hash', settingHash);
- localStorage.removeItem('weighted-settings');
- }
-
- if (settingHash !== md5(JSON.stringify(results))) {
- const userMessage = document.getElementById('user-message');
- userMessage.innerText = "Your settings are out of date! Click here to update them! Be aware this will reset " +
- "them all to default.";
- userMessage.classList.add('visible');
- userMessage.addEventListener('click', resetSettings);
- }
-
- // Page setup
- createDefaultSettings(results);
- buildUI(results);
- updateVisibleGames();
- adjustHeaderWidth();
-
- // Event listeners
- document.getElementById('export-settings').addEventListener('click', () => exportSettings());
- document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
- document.getElementById('generate-game').addEventListener('click', () => generateGame());
-
- // Name input field
- const weightedSettings = JSON.parse(localStorage.getItem('weighted-settings'));
- const nameInput = document.getElementById('player-name');
- nameInput.setAttribute('data-type', 'data');
- nameInput.setAttribute('data-setting', 'name');
- nameInput.addEventListener('keyup', updateBaseSetting);
- nameInput.value = weightedSettings.name;
- });
-});
-
-const resetSettings = () => {
- localStorage.removeItem('weighted-settings');
- localStorage.removeItem('weighted-settings-hash')
- window.location.reload();
-};
-
-const fetchSettingData = () => new Promise((resolve, reject) => {
- fetch(new Request(`${window.location.origin}/static/generated/weighted-settings.json`)).then((response) => {
- try{ response.json().then((jsonObj) => resolve(jsonObj)); }
- catch(error){ reject(error); }
- });
-});
-
-const createDefaultSettings = (settingData) => {
- if (!localStorage.getItem('weighted-settings')) {
- const newSettings = {};
-
- // Transfer base options directly
- for (let baseOption of Object.keys(settingData.baseOptions)){
- newSettings[baseOption] = settingData.baseOptions[baseOption];
- }
-
- // Set options per game
- for (let game of Object.keys(settingData.games)) {
- // Initialize game object
- newSettings[game] = {};
-
- // Transfer game settings
- for (let gameSetting of Object.keys(settingData.games[game].gameSettings)){
- newSettings[game][gameSetting] = {};
-
- const setting = settingData.games[game].gameSettings[gameSetting];
- switch(setting.type){
- case 'select':
- setting.options.forEach((option) => {
- newSettings[game][gameSetting][option.value] =
- (setting.hasOwnProperty('defaultValue') && setting.defaultValue === option.value) ? 25 : 0;
- });
- break;
- case 'range':
- case 'special_range':
- newSettings[game][gameSetting]['random'] = 0;
- newSettings[game][gameSetting]['random-low'] = 0;
- newSettings[game][gameSetting]['random-high'] = 0;
- if (setting.hasOwnProperty('defaultValue')) {
- newSettings[game][gameSetting][setting.defaultValue] = 25;
- } else {
- newSettings[game][gameSetting][setting.min] = 25;
- }
- break;
-
- case 'items-list':
- case 'locations-list':
- case 'custom-list':
- newSettings[game][gameSetting] = setting.defaultValue;
- break;
-
- default:
- console.error(`Unknown setting type for ${game} setting ${gameSetting}: ${setting.type}`);
- }
- }
-
- newSettings[game].start_inventory = {};
- newSettings[game].exclude_locations = [];
- newSettings[game].priority_locations = [];
- newSettings[game].local_items = [];
- newSettings[game].non_local_items = [];
- newSettings[game].start_hints = [];
- newSettings[game].start_location_hints = [];
- }
-
- localStorage.setItem('weighted-settings', JSON.stringify(newSettings));
- }
-};
-
-const buildUI = (settingData) => {
- // Build the game-choice div
- buildGameChoice(settingData.games);
-
- const gamesWrapper = document.getElementById('games-wrapper');
- Object.keys(settingData.games).forEach((game) => {
- // Create game div, invisible by default
- const gameDiv = document.createElement('div');
- gameDiv.setAttribute('id', `${game}-div`);
- gameDiv.classList.add('game-div');
- gameDiv.classList.add('invisible');
-
- const gameHeader = document.createElement('h2');
- gameHeader.innerText = game;
- gameDiv.appendChild(gameHeader);
-
- const collapseButton = document.createElement('a');
- collapseButton.innerText = '(Collapse)';
- gameDiv.appendChild(collapseButton);
-
- const expandButton = document.createElement('a');
- expandButton.innerText = '(Expand)';
- expandButton.classList.add('invisible');
- gameDiv.appendChild(expandButton);
-
- settingData.games[game].gameItems.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0)));
- settingData.games[game].gameLocations.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0)));
-
- const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings,
- settingData.games[game].gameItems, settingData.games[game].gameLocations);
- gameDiv.appendChild(weightedSettingsDiv);
-
- const itemPoolDiv = buildItemsDiv(game, settingData.games[game].gameItems);
- gameDiv.appendChild(itemPoolDiv);
-
- const hintsDiv = buildHintsDiv(game, settingData.games[game].gameItems, settingData.games[game].gameLocations);
- gameDiv.appendChild(hintsDiv);
-
- const locationsDiv = buildLocationsDiv(game, settingData.games[game].gameLocations);
- gameDiv.appendChild(locationsDiv);
-
- gamesWrapper.appendChild(gameDiv);
-
- collapseButton.addEventListener('click', () => {
- collapseButton.classList.add('invisible');
- weightedSettingsDiv.classList.add('invisible');
- itemPoolDiv.classList.add('invisible');
- hintsDiv.classList.add('invisible');
- expandButton.classList.remove('invisible');
- });
-
- expandButton.addEventListener('click', () => {
- collapseButton.classList.remove('invisible');
- weightedSettingsDiv.classList.remove('invisible');
- itemPoolDiv.classList.remove('invisible');
- hintsDiv.classList.remove('invisible');
- expandButton.classList.add('invisible');
- });
- });
-};
-
-const buildGameChoice = (games) => {
- const settings = JSON.parse(localStorage.getItem('weighted-settings'));
- const gameChoiceDiv = document.getElementById('game-choice');
- const h2 = document.createElement('h2');
- h2.innerText = 'Game Select';
- gameChoiceDiv.appendChild(h2);
-
- const gameSelectDescription = document.createElement('p');
- gameSelectDescription.classList.add('setting-description');
- gameSelectDescription.innerText = 'Choose which games you might be required to play.';
- gameChoiceDiv.appendChild(gameSelectDescription);
-
- const hintText = document.createElement('p');
- hintText.classList.add('hint-text');
- hintText.innerText = 'If a game\'s value is greater than zero, you can click it\'s name to jump ' +
- 'to that section.'
- gameChoiceDiv.appendChild(hintText);
-
- // Build the game choice table
- const table = document.createElement('table');
- const tbody = document.createElement('tbody');
-
- Object.keys(games).forEach((game) => {
- const tr = document.createElement('tr');
- const tdLeft = document.createElement('td');
- tdLeft.classList.add('td-left');
- const span = document.createElement('span');
- span.innerText = game;
- span.setAttribute('id', `${game}-game-option`)
- tdLeft.appendChild(span);
- tr.appendChild(tdLeft);
-
- const tdMiddle = document.createElement('td');
- tdMiddle.classList.add('td-middle');
- const range = document.createElement('input');
- range.setAttribute('type', 'range');
- range.setAttribute('min', 0);
- range.setAttribute('max', 50);
- range.setAttribute('data-type', 'weight');
- range.setAttribute('data-setting', 'game');
- range.setAttribute('data-option', game);
- range.value = settings.game[game];
- range.addEventListener('change', (evt) => {
- updateBaseSetting(evt);
- updateVisibleGames(); // Show or hide games based on the new settings
- });
- tdMiddle.appendChild(range);
- tr.appendChild(tdMiddle);
-
- const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `game-${game}`)
- tdRight.classList.add('td-right');
- tdRight.innerText = range.value;
- tr.appendChild(tdRight);
- tbody.appendChild(tr);
- });
-
- table.appendChild(tbody);
- gameChoiceDiv.appendChild(table);
-};
-
-const buildWeightedSettingsDiv = (game, settings, gameItems, gameLocations) => {
- const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
- const settingsWrapper = document.createElement('div');
- settingsWrapper.classList.add('settings-wrapper');
-
- Object.keys(settings).forEach((settingName) => {
- const setting = settings[settingName];
- const settingWrapper = document.createElement('div');
- settingWrapper.classList.add('setting-wrapper');
-
- const settingNameHeader = document.createElement('h4');
- settingNameHeader.innerText = setting.displayName;
- settingWrapper.appendChild(settingNameHeader);
-
- const settingDescription = document.createElement('p');
- settingDescription.classList.add('setting-description');
- settingDescription.innerText = setting.description.replace(/(\n)/g, ' ');
- settingWrapper.appendChild(settingDescription);
-
- switch(setting.type){
- case 'select':
- const optionTable = document.createElement('table');
- const tbody = document.createElement('tbody');
-
- // Add a weight range for each option
- setting.options.forEach((option) => {
- const tr = document.createElement('tr');
- const tdLeft = document.createElement('td');
- tdLeft.classList.add('td-left');
- tdLeft.innerText = option.name;
- tr.appendChild(tdLeft);
-
- const tdMiddle = document.createElement('td');
- tdMiddle.classList.add('td-middle');
- const range = document.createElement('input');
- range.setAttribute('type', 'range');
- range.setAttribute('data-game', game);
- range.setAttribute('data-setting', settingName);
- range.setAttribute('data-option', option.value);
- range.setAttribute('data-type', setting.type);
- range.setAttribute('min', 0);
- range.setAttribute('max', 50);
- range.addEventListener('change', updateRangeSetting);
- range.value = currentSettings[game][settingName][option.value];
- tdMiddle.appendChild(range);
- tr.appendChild(tdMiddle);
-
- const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `${game}-${settingName}-${option.value}`)
- tdRight.classList.add('td-right');
- tdRight.innerText = range.value;
- tr.appendChild(tdRight);
-
- tbody.appendChild(tr);
- });
-
- optionTable.appendChild(tbody);
- settingWrapper.appendChild(optionTable);
- break;
-
- case 'range':
- case 'special_range':
- const rangeTable = document.createElement('table');
- const rangeTbody = document.createElement('tbody');
-
- if (((setting.max - setting.min) + 1) < 11) {
- for (let i=setting.min; i <= setting.max; ++i) {
- const tr = document.createElement('tr');
- const tdLeft = document.createElement('td');
- tdLeft.classList.add('td-left');
- tdLeft.innerText = i;
- tr.appendChild(tdLeft);
-
- const tdMiddle = document.createElement('td');
- tdMiddle.classList.add('td-middle');
- const range = document.createElement('input');
- range.setAttribute('type', 'range');
- range.setAttribute('id', `${game}-${settingName}-${i}-range`);
- range.setAttribute('data-game', game);
- range.setAttribute('data-setting', settingName);
- range.setAttribute('data-option', i);
- range.setAttribute('min', 0);
- range.setAttribute('max', 50);
- range.addEventListener('change', updateRangeSetting);
- range.value = currentSettings[game][settingName][i] || 0;
- tdMiddle.appendChild(range);
- tr.appendChild(tdMiddle);
-
- const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `${game}-${settingName}-${i}`)
- tdRight.classList.add('td-right');
- tdRight.innerText = range.value;
- tr.appendChild(tdRight);
-
- rangeTbody.appendChild(tr);
- }
- } else {
- const hintText = document.createElement('p');
- hintText.classList.add('hint-text');
- hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' +
- `below, then press the "Add" button to add a weight for it. Minimum value: ${setting.min} ` +
- `Maximum value: ${setting.max}`;
-
- if (setting.hasOwnProperty('value_names')) {
- hintText.innerHTML += ' Certain values have special meaning:';
- Object.keys(setting.value_names).forEach((specialName) => {
- hintText.innerHTML += ` ${specialName}: ${setting.value_names[specialName]}`;
- });
- }
-
- settingWrapper.appendChild(hintText);
-
- const addOptionDiv = document.createElement('div');
- addOptionDiv.classList.add('add-option-div');
- const optionInput = document.createElement('input');
- optionInput.setAttribute('id', `${game}-${settingName}-option`);
- optionInput.setAttribute('placeholder', `${setting.min} - ${setting.max}`);
- addOptionDiv.appendChild(optionInput);
- const addOptionButton = document.createElement('button');
- addOptionButton.innerText = 'Add';
- addOptionDiv.appendChild(addOptionButton);
- settingWrapper.appendChild(addOptionDiv);
- optionInput.addEventListener('keydown', (evt) => {
- if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); }
- });
-
- addOptionButton.addEventListener('click', () => {
- const optionInput = document.getElementById(`${game}-${settingName}-option`);
- let option = optionInput.value;
- if (!option || !option.trim()) { return; }
- option = parseInt(option, 10);
- if ((option < setting.min) || (option > setting.max)) { return; }
- optionInput.value = '';
- if (document.getElementById(`${game}-${settingName}-${option}-range`)) { return; }
-
- const tr = document.createElement('tr');
- const tdLeft = document.createElement('td');
- tdLeft.classList.add('td-left');
- tdLeft.innerText = option;
- tr.appendChild(tdLeft);
-
- const tdMiddle = document.createElement('td');
- tdMiddle.classList.add('td-middle');
- const range = document.createElement('input');
- range.setAttribute('type', 'range');
- range.setAttribute('id', `${game}-${settingName}-${option}-range`);
- range.setAttribute('data-game', game);
- range.setAttribute('data-setting', settingName);
- range.setAttribute('data-option', option);
- range.setAttribute('min', 0);
- range.setAttribute('max', 50);
- range.addEventListener('change', updateRangeSetting);
- range.value = currentSettings[game][settingName][parseInt(option, 10)];
- tdMiddle.appendChild(range);
- tr.appendChild(tdMiddle);
-
- const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `${game}-${settingName}-${option}`)
- tdRight.classList.add('td-right');
- tdRight.innerText = range.value;
- tr.appendChild(tdRight);
-
- const tdDelete = document.createElement('td');
- tdDelete.classList.add('td-delete');
- const deleteButton = document.createElement('span');
- deleteButton.classList.add('range-option-delete');
- deleteButton.innerText = 'â';
- deleteButton.addEventListener('click', () => {
- range.value = 0;
- range.dispatchEvent(new Event('change'));
- rangeTbody.removeChild(tr);
- });
- tdDelete.appendChild(deleteButton);
- tr.appendChild(tdDelete);
-
- rangeTbody.appendChild(tr);
-
- // Save new option to settings
- range.dispatchEvent(new Event('change'));
- });
-
- Object.keys(currentSettings[game][settingName]).forEach((option) => {
- // These options are statically generated below, and should always appear even if they are deleted
- // from localStorage
- if (['random-low', 'random', 'random-high'].includes(option)) { return; }
-
- const tr = document.createElement('tr');
- const tdLeft = document.createElement('td');
- tdLeft.classList.add('td-left');
- tdLeft.innerText = option;
- tr.appendChild(tdLeft);
-
- const tdMiddle = document.createElement('td');
- tdMiddle.classList.add('td-middle');
- const range = document.createElement('input');
- range.setAttribute('type', 'range');
- range.setAttribute('id', `${game}-${settingName}-${option}-range`);
- range.setAttribute('data-game', game);
- range.setAttribute('data-setting', settingName);
- range.setAttribute('data-option', option);
- range.setAttribute('min', 0);
- range.setAttribute('max', 50);
- range.addEventListener('change', updateRangeSetting);
- range.value = currentSettings[game][settingName][parseInt(option, 10)];
- tdMiddle.appendChild(range);
- tr.appendChild(tdMiddle);
-
- const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `${game}-${settingName}-${option}`)
- tdRight.classList.add('td-right');
- tdRight.innerText = range.value;
- tr.appendChild(tdRight);
-
- const tdDelete = document.createElement('td');
- tdDelete.classList.add('td-delete');
- const deleteButton = document.createElement('span');
- deleteButton.classList.add('range-option-delete');
- deleteButton.innerText = 'â';
- deleteButton.addEventListener('click', () => {
- range.value = 0;
- const changeEvent = new Event('change');
- changeEvent.action = 'rangeDelete';
- range.dispatchEvent(changeEvent);
- rangeTbody.removeChild(tr);
- });
- tdDelete.appendChild(deleteButton);
- tr.appendChild(tdDelete);
-
- rangeTbody.appendChild(tr);
- });
- }
-
- ['random', 'random-low', 'random-high'].forEach((option) => {
- const tr = document.createElement('tr');
- const tdLeft = document.createElement('td');
- tdLeft.classList.add('td-left');
- switch(option){
- case 'random':
- tdLeft.innerText = 'Random';
- break;
- case 'random-low':
- tdLeft.innerText = "Random (Low)";
- break;
- case 'random-high':
- tdLeft.innerText = "Random (High)";
- break;
- }
- tr.appendChild(tdLeft);
-
- const tdMiddle = document.createElement('td');
- tdMiddle.classList.add('td-middle');
- const range = document.createElement('input');
- range.setAttribute('type', 'range');
- range.setAttribute('id', `${game}-${settingName}-${option}-range`);
- range.setAttribute('data-game', game);
- range.setAttribute('data-setting', settingName);
- range.setAttribute('data-option', option);
- range.setAttribute('min', 0);
- range.setAttribute('max', 50);
- range.addEventListener('change', updateRangeSetting);
- range.value = currentSettings[game][settingName][option];
- tdMiddle.appendChild(range);
- tr.appendChild(tdMiddle);
-
- const tdRight = document.createElement('td');
- tdRight.setAttribute('id', `${game}-${settingName}-${option}`)
- tdRight.classList.add('td-right');
- tdRight.innerText = range.value;
- tr.appendChild(tdRight);
- rangeTbody.appendChild(tr);
- });
-
- rangeTable.appendChild(rangeTbody);
- settingWrapper.appendChild(rangeTable);
- break;
-
- case 'items-list':
- const itemsList = document.createElement('div');
- itemsList.classList.add('simple-list');
-
- Object.values(gameItems).forEach((item) => {
- const itemRow = document.createElement('div');
- itemRow.classList.add('list-row');
-
- const itemLabel = document.createElement('label');
- itemLabel.setAttribute('for', `${game}-${settingName}-${item}`)
-
- const itemCheckbox = document.createElement('input');
- itemCheckbox.setAttribute('id', `${game}-${settingName}-${item}`);
- itemCheckbox.setAttribute('type', 'checkbox');
- itemCheckbox.setAttribute('data-game', game);
- itemCheckbox.setAttribute('data-setting', settingName);
- itemCheckbox.setAttribute('data-option', item.toString());
- itemCheckbox.addEventListener('change', updateListSetting);
- if (currentSettings[game][settingName].includes(item)) {
- itemCheckbox.setAttribute('checked', '1');
- }
-
- const itemName = document.createElement('span');
- itemName.innerText = item.toString();
-
- itemLabel.appendChild(itemCheckbox);
- itemLabel.appendChild(itemName);
-
- itemRow.appendChild(itemLabel);
- itemsList.appendChild((itemRow));
- });
-
- settingWrapper.appendChild(itemsList);
- break;
-
- case 'locations-list':
- const locationsList = document.createElement('div');
- locationsList.classList.add('simple-list');
-
- Object.values(gameLocations).forEach((location) => {
- const locationRow = document.createElement('div');
- locationRow.classList.add('list-row');
-
- const locationLabel = document.createElement('label');
- locationLabel.setAttribute('for', `${game}-${settingName}-${location}`)
-
- const locationCheckbox = document.createElement('input');
- locationCheckbox.setAttribute('id', `${game}-${settingName}-${location}`);
- locationCheckbox.setAttribute('type', 'checkbox');
- locationCheckbox.setAttribute('data-game', game);
- locationCheckbox.setAttribute('data-setting', settingName);
- locationCheckbox.setAttribute('data-option', location.toString());
- locationCheckbox.addEventListener('change', updateListSetting);
- if (currentSettings[game][settingName].includes(location)) {
- locationCheckbox.setAttribute('checked', '1');
- }
-
- const locationName = document.createElement('span');
- locationName.innerText = location.toString();
-
- locationLabel.appendChild(locationCheckbox);
- locationLabel.appendChild(locationName);
-
- locationRow.appendChild(locationLabel);
- locationsList.appendChild((locationRow));
- });
-
- settingWrapper.appendChild(locationsList);
- break;
-
- case 'custom-list':
- const customList = document.createElement('div');
- customList.classList.add('simple-list');
-
- Object.values(settings[settingName].options).forEach((listItem) => {
- const customListRow = document.createElement('div');
- customListRow.classList.add('list-row');
-
- const customItemLabel = document.createElement('label');
- customItemLabel.setAttribute('for', `${game}-${settingName}-${listItem}`)
-
- const customItemCheckbox = document.createElement('input');
- customItemCheckbox.setAttribute('id', `${game}-${settingName}-${listItem}`);
- customItemCheckbox.setAttribute('type', 'checkbox');
- customItemCheckbox.setAttribute('data-game', game);
- customItemCheckbox.setAttribute('data-setting', settingName);
- customItemCheckbox.setAttribute('data-option', listItem.toString());
- customItemCheckbox.addEventListener('change', updateListSetting);
- if (currentSettings[game][settingName].includes(listItem)) {
- customItemCheckbox.setAttribute('checked', '1');
- }
-
- const customItemName = document.createElement('span');
- customItemName.innerText = listItem.toString();
-
- customItemLabel.appendChild(customItemCheckbox);
- customItemLabel.appendChild(customItemName);
-
- customListRow.appendChild(customItemLabel);
- customList.appendChild((customListRow));
- });
-
- settingWrapper.appendChild(customList);
- break;
-
- default:
- console.error(`Unknown setting type for ${game} setting ${settingName}: ${setting.type}`);
- return;
- }
-
- settingsWrapper.appendChild(settingWrapper);
- });
-
- return settingsWrapper;
-};
-
-const buildItemsDiv = (game, items) => {
- // Sort alphabetical, in pace
- items.sort();
-
- const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
- const itemsDiv = document.createElement('div');
- itemsDiv.classList.add('items-div');
-
- const itemsDivHeader = document.createElement('h3');
- itemsDivHeader.innerText = 'Item Pool';
- itemsDiv.appendChild(itemsDivHeader);
-
- const itemsDescription = document.createElement('p');
- itemsDescription.classList.add('setting-description');
- itemsDescription.innerText = 'Choose if you would like to start with items, or control if they are placed in ' +
- 'your seed or someone else\'s.';
- itemsDiv.appendChild(itemsDescription);
-
- const itemsHint = document.createElement('p');
- itemsHint.classList.add('hint-text');
- itemsHint.innerText = 'Drag and drop items from one box to another.';
- itemsDiv.appendChild(itemsHint);
-
- const itemsWrapper = document.createElement('div');
- itemsWrapper.classList.add('items-wrapper');
-
- // Create container divs for each category
- const availableItemsWrapper = document.createElement('div');
- availableItemsWrapper.classList.add('item-set-wrapper');
- availableItemsWrapper.innerText = 'Available Items';
- const availableItems = document.createElement('div');
- availableItems.classList.add('item-container');
- availableItems.setAttribute('id', `${game}-available_items`);
- availableItems.addEventListener('dragover', itemDragoverHandler);
- availableItems.addEventListener('drop', itemDropHandler);
-
- const startInventoryWrapper = document.createElement('div');
- startInventoryWrapper.classList.add('item-set-wrapper');
- startInventoryWrapper.innerText = 'Start Inventory';
- const startInventory = document.createElement('div');
- startInventory.classList.add('item-container');
- startInventory.setAttribute('id', `${game}-start_inventory`);
- startInventory.setAttribute('data-setting', 'start_inventory');
- startInventory.addEventListener('dragover', itemDragoverHandler);
- startInventory.addEventListener('drop', itemDropHandler);
-
- const localItemsWrapper = document.createElement('div');
- localItemsWrapper.classList.add('item-set-wrapper');
- localItemsWrapper.innerText = 'Local Items';
- const localItems = document.createElement('div');
- localItems.classList.add('item-container');
- localItems.setAttribute('id', `${game}-local_items`);
- localItems.setAttribute('data-setting', 'local_items')
- localItems.addEventListener('dragover', itemDragoverHandler);
- localItems.addEventListener('drop', itemDropHandler);
-
- const nonLocalItemsWrapper = document.createElement('div');
- nonLocalItemsWrapper.classList.add('item-set-wrapper');
- nonLocalItemsWrapper.innerText = 'Non-Local Items';
- const nonLocalItems = document.createElement('div');
- nonLocalItems.classList.add('item-container');
- nonLocalItems.setAttribute('id', `${game}-non_local_items`);
- nonLocalItems.setAttribute('data-setting', 'non_local_items');
- nonLocalItems.addEventListener('dragover', itemDragoverHandler);
- nonLocalItems.addEventListener('drop', itemDropHandler);
-
- // Populate the divs
- items.forEach((item) => {
- if (Object.keys(currentSettings[game].start_inventory).includes(item)){
- const itemDiv = buildItemQtyDiv(game, item);
- itemDiv.setAttribute('data-setting', 'start_inventory');
- startInventory.appendChild(itemDiv);
- } else if (currentSettings[game].local_items.includes(item)) {
- const itemDiv = buildItemDiv(game, item);
- itemDiv.setAttribute('data-setting', 'local_items');
- localItems.appendChild(itemDiv);
- } else if (currentSettings[game].non_local_items.includes(item)) {
- const itemDiv = buildItemDiv(game, item);
- itemDiv.setAttribute('data-setting', 'non_local_items');
- nonLocalItems.appendChild(itemDiv);
- } else {
- const itemDiv = buildItemDiv(game, item);
- availableItems.appendChild(itemDiv);
- }
- });
-
- availableItemsWrapper.appendChild(availableItems);
- startInventoryWrapper.appendChild(startInventory);
- localItemsWrapper.appendChild(localItems);
- nonLocalItemsWrapper.appendChild(nonLocalItems);
- itemsWrapper.appendChild(availableItemsWrapper);
- itemsWrapper.appendChild(startInventoryWrapper);
- itemsWrapper.appendChild(localItemsWrapper);
- itemsWrapper.appendChild(nonLocalItemsWrapper);
- itemsDiv.appendChild(itemsWrapper);
- return itemsDiv;
-};
-
-const buildItemDiv = (game, item) => {
- const itemDiv = document.createElement('div');
- itemDiv.classList.add('item-div');
- itemDiv.setAttribute('id', `${game}-${item}`);
- itemDiv.setAttribute('data-game', game);
- itemDiv.setAttribute('data-item', item);
- itemDiv.setAttribute('draggable', 'true');
- itemDiv.innerText = item;
- itemDiv.addEventListener('dragstart', (evt) => {
- evt.dataTransfer.setData('text/plain', itemDiv.getAttribute('id'));
- });
- return itemDiv;
-};
-
-const buildItemQtyDiv = (game, item) => {
- const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
- const itemQtyDiv = document.createElement('div');
- itemQtyDiv.classList.add('item-qty-div');
- itemQtyDiv.setAttribute('id', `${game}-${item}`);
- itemQtyDiv.setAttribute('data-game', game);
- itemQtyDiv.setAttribute('data-item', item);
- itemQtyDiv.setAttribute('draggable', 'true');
- itemQtyDiv.innerText = item;
-
- const inputWrapper = document.createElement('div');
- inputWrapper.classList.add('item-qty-input-wrapper')
-
- const itemQty = document.createElement('input');
- itemQty.setAttribute('value', currentSettings[game].start_inventory.hasOwnProperty(item) ?
- currentSettings[game].start_inventory[item] : '1');
- itemQty.setAttribute('data-game', game);
- itemQty.setAttribute('data-setting', 'start_inventory');
- itemQty.setAttribute('data-option', item);
- itemQty.setAttribute('maxlength', '3');
- itemQty.addEventListener('keyup', (evt) => {
- evt.target.value = isNaN(parseInt(evt.target.value)) ? 0 : parseInt(evt.target.value);
- updateItemSetting(evt);
- });
- inputWrapper.appendChild(itemQty);
- itemQtyDiv.appendChild(inputWrapper);
-
- itemQtyDiv.addEventListener('dragstart', (evt) => {
- evt.dataTransfer.setData('text/plain', itemQtyDiv.getAttribute('id'));
- });
- return itemQtyDiv;
-};
-
-const itemDragoverHandler = (evt) => {
- evt.preventDefault();
-};
-
-const itemDropHandler = (evt) => {
- evt.preventDefault();
- const sourceId = evt.dataTransfer.getData('text/plain');
- const sourceDiv = document.getElementById(sourceId);
-
- const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
- const game = sourceDiv.getAttribute('data-game');
- const item = sourceDiv.getAttribute('data-item');
-
- const oldSetting = sourceDiv.hasAttribute('data-setting') ? sourceDiv.getAttribute('data-setting') : null;
- const newSetting = evt.target.hasAttribute('data-setting') ? evt.target.getAttribute('data-setting') : null;
-
- const itemDiv = newSetting === 'start_inventory' ? buildItemQtyDiv(game, item) : buildItemDiv(game, item);
-
- if (oldSetting) {
- if (oldSetting === 'start_inventory') {
- if (currentSettings[game][oldSetting].hasOwnProperty(item)) {
- delete currentSettings[game][oldSetting][item];
- }
- } else {
- if (currentSettings[game][oldSetting].includes(item)) {
- currentSettings[game][oldSetting].splice(currentSettings[game][oldSetting].indexOf(item), 1);
- }
- }
- }
-
- if (newSetting) {
- itemDiv.setAttribute('data-setting', newSetting);
- document.getElementById(`${game}-${newSetting}`).appendChild(itemDiv);
- if (newSetting === 'start_inventory') {
- currentSettings[game][newSetting][item] = 1;
- } else {
- if (!currentSettings[game][newSetting].includes(item)){
- currentSettings[game][newSetting].push(item);
- }
- }
- } else {
- // No setting was assigned, this item has been removed from the settings
- document.getElementById(`${game}-available_items`).appendChild(itemDiv);
- }
-
- // Remove the source drag object
- sourceDiv.parentElement.removeChild(sourceDiv);
-
- // Save the updated settings
- localStorage.setItem('weighted-settings', JSON.stringify(currentSettings));
-};
-
-const buildHintsDiv = (game, items, locations) => {
- const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
-
- // Sort alphabetical, in place
- items.sort();
- locations.sort();
-
- const hintsDiv = document.createElement('div');
- hintsDiv.classList.add('hints-div');
- const hintsHeader = document.createElement('h3');
- hintsHeader.innerText = 'Item & Location Hints';
- hintsDiv.appendChild(hintsHeader);
- const hintsDescription = document.createElement('p');
- hintsDescription.classList.add('setting-description');
- hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' +
- ' items are, or what those locations contain.';
- hintsDiv.appendChild(hintsDescription);
-
- const itemHintsContainer = document.createElement('div');
- itemHintsContainer.classList.add('hints-container');
-
- // Item Hints
- const itemHintsWrapper = document.createElement('div');
- itemHintsWrapper.classList.add('hints-wrapper');
- itemHintsWrapper.innerText = 'Starting Item Hints';
-
- const itemHintsDiv = document.createElement('div');
- itemHintsDiv.classList.add('simple-list');
- items.forEach((item) => {
- const itemRow = document.createElement('div');
- itemRow.classList.add('list-row');
-
- const itemLabel = document.createElement('label');
- itemLabel.setAttribute('for', `${game}-start_hints-${item}`);
-
- const itemCheckbox = document.createElement('input');
- itemCheckbox.setAttribute('type', 'checkbox');
- itemCheckbox.setAttribute('id', `${game}-start_hints-${item}`);
- itemCheckbox.setAttribute('data-game', game);
- itemCheckbox.setAttribute('data-setting', 'start_hints');
- itemCheckbox.setAttribute('data-option', item);
- if (currentSettings[game].start_hints.includes(item)) {
- itemCheckbox.setAttribute('checked', 'true');
- }
- itemCheckbox.addEventListener('change', updateListSetting);
- itemLabel.appendChild(itemCheckbox);
-
- const itemName = document.createElement('span');
- itemName.innerText = item;
- itemLabel.appendChild(itemName);
-
- itemRow.appendChild(itemLabel);
- itemHintsDiv.appendChild(itemRow);
- });
-
- itemHintsWrapper.appendChild(itemHintsDiv);
- itemHintsContainer.appendChild(itemHintsWrapper);
-
- // Starting Location Hints
- const locationHintsWrapper = document.createElement('div');
- locationHintsWrapper.classList.add('hints-wrapper');
- locationHintsWrapper.innerText = 'Starting Location Hints';
-
- const locationHintsDiv = document.createElement('div');
- locationHintsDiv.classList.add('simple-list');
- locations.forEach((location) => {
- const locationRow = document.createElement('div');
- locationRow.classList.add('list-row');
-
- const locationLabel = document.createElement('label');
- locationLabel.setAttribute('for', `${game}-start_location_hints-${location}`);
-
- const locationCheckbox = document.createElement('input');
- locationCheckbox.setAttribute('type', 'checkbox');
- locationCheckbox.setAttribute('id', `${game}-start_location_hints-${location}`);
- locationCheckbox.setAttribute('data-game', game);
- locationCheckbox.setAttribute('data-setting', 'start_location_hints');
- locationCheckbox.setAttribute('data-option', location);
- if (currentSettings[game].start_location_hints.includes(location)) {
- locationCheckbox.setAttribute('checked', '1');
- }
- locationCheckbox.addEventListener('change', updateListSetting);
- locationLabel.appendChild(locationCheckbox);
-
- const locationName = document.createElement('span');
- locationName.innerText = location;
- locationLabel.appendChild(locationName);
-
- locationRow.appendChild(locationLabel);
- locationHintsDiv.appendChild(locationRow);
- });
-
- locationHintsWrapper.appendChild(locationHintsDiv);
- itemHintsContainer.appendChild(locationHintsWrapper);
-
- hintsDiv.appendChild(itemHintsContainer);
- return hintsDiv;
-};
-
-const buildLocationsDiv = (game, locations) => {
- const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
- locations.sort(); // Sort alphabetical, in-place
-
- const locationsDiv = document.createElement('div');
- locationsDiv.classList.add('locations-div');
- const locationsHeader = document.createElement('h3');
- locationsHeader.innerText = 'Priority & Exclusion Locations';
- locationsDiv.appendChild(locationsHeader);
- const locationsDescription = document.createElement('p');
- locationsDescription.classList.add('setting-description');
- locationsDescription.innerText = 'Priority locations guarantee a progression item will be placed there while ' +
- 'excluded locations will not contain progression or useful items.';
- locationsDiv.appendChild(locationsDescription);
-
- const locationsContainer = document.createElement('div');
- locationsContainer.classList.add('locations-container');
-
- // Priority Locations
- const priorityLocationsWrapper = document.createElement('div');
- priorityLocationsWrapper.classList.add('locations-wrapper');
- priorityLocationsWrapper.innerText = 'Priority Locations';
-
- const priorityLocationsDiv = document.createElement('div');
- priorityLocationsDiv.classList.add('simple-list');
- locations.forEach((location) => {
- const locationRow = document.createElement('div');
- locationRow.classList.add('list-row');
-
- const locationLabel = document.createElement('label');
- locationLabel.setAttribute('for', `${game}-priority_locations-${location}`);
-
- const locationCheckbox = document.createElement('input');
- locationCheckbox.setAttribute('type', 'checkbox');
- locationCheckbox.setAttribute('id', `${game}-priority_locations-${location}`);
- locationCheckbox.setAttribute('data-game', game);
- locationCheckbox.setAttribute('data-setting', 'priority_locations');
- locationCheckbox.setAttribute('data-option', location);
- if (currentSettings[game].priority_locations.includes(location)) {
- locationCheckbox.setAttribute('checked', '1');
- }
- locationCheckbox.addEventListener('change', updateListSetting);
- locationLabel.appendChild(locationCheckbox);
-
- const locationName = document.createElement('span');
- locationName.innerText = location;
- locationLabel.appendChild(locationName);
-
- locationRow.appendChild(locationLabel);
- priorityLocationsDiv.appendChild(locationRow);
- });
-
- priorityLocationsWrapper.appendChild(priorityLocationsDiv);
- locationsContainer.appendChild(priorityLocationsWrapper);
-
- // Exclude Locations
- const excludeLocationsWrapper = document.createElement('div');
- excludeLocationsWrapper.classList.add('locations-wrapper');
- excludeLocationsWrapper.innerText = 'Exclude Locations';
-
- const excludeLocationsDiv = document.createElement('div');
- excludeLocationsDiv.classList.add('simple-list');
- locations.forEach((location) => {
- const locationRow = document.createElement('div');
- locationRow.classList.add('list-row');
-
- const locationLabel = document.createElement('label');
- locationLabel.setAttribute('for', `${game}-exclude_locations-${location}`);
-
- const locationCheckbox = document.createElement('input');
- locationCheckbox.setAttribute('type', 'checkbox');
- locationCheckbox.setAttribute('id', `${game}-exclude_locations-${location}`);
- locationCheckbox.setAttribute('data-game', game);
- locationCheckbox.setAttribute('data-setting', 'exclude_locations');
- locationCheckbox.setAttribute('data-option', location);
- if (currentSettings[game].exclude_locations.includes(location)) {
- locationCheckbox.setAttribute('checked', '1');
- }
- locationCheckbox.addEventListener('change', updateListSetting);
- locationLabel.appendChild(locationCheckbox);
-
- const locationName = document.createElement('span');
- locationName.innerText = location;
- locationLabel.appendChild(locationName);
-
- locationRow.appendChild(locationLabel);
- excludeLocationsDiv.appendChild(locationRow);
- });
-
- excludeLocationsWrapper.appendChild(excludeLocationsDiv);
- locationsContainer.appendChild(excludeLocationsWrapper);
-
- locationsDiv.appendChild(locationsContainer);
- return locationsDiv;
-};
-
-const updateVisibleGames = () => {
- const settings = JSON.parse(localStorage.getItem('weighted-settings'));
- Object.keys(settings.game).forEach((game) => {
- const gameDiv = document.getElementById(`${game}-div`);
- const gameOption = document.getElementById(`${game}-game-option`);
- if (parseInt(settings.game[game], 10) > 0) {
- gameDiv.classList.remove('invisible');
- gameOption.classList.add('jump-link');
- gameOption.addEventListener('click', () => {
- const gameDiv = document.getElementById(`${game}-div`);
- if (gameDiv.classList.contains('invisible')) { return; }
- gameDiv.scrollIntoView({
- behavior: 'smooth',
- block: 'start',
- });
- });
- } else {
- gameDiv.classList.add('invisible');
- gameOption.classList.remove('jump-link');
-
- }
- });
-};
-
-const updateBaseSetting = (event) => {
- const settings = JSON.parse(localStorage.getItem('weighted-settings'));
- const setting = event.target.getAttribute('data-setting');
- const option = event.target.getAttribute('data-option');
- const type = event.target.getAttribute('data-type');
-
- switch(type){
- case 'weight':
- settings[setting][option] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10);
- document.getElementById(`${setting}-${option}`).innerText = event.target.value;
- break;
- case 'data':
- settings[setting] = isNaN(event.target.value) ? event.target.value : parseInt(event.target.value, 10);
- break;
- }
-
- localStorage.setItem('weighted-settings', JSON.stringify(settings));
-};
-
-const updateRangeSetting = (evt) => {
- const options = JSON.parse(localStorage.getItem('weighted-settings'));
- const game = evt.target.getAttribute('data-game');
- const setting = evt.target.getAttribute('data-setting');
- const option = evt.target.getAttribute('data-option');
- document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
- if (evt.action && evt.action === 'rangeDelete') {
- delete options[game][setting][option];
- } else {
- options[game][setting][option] = parseInt(evt.target.value, 10);
- }
- localStorage.setItem('weighted-settings', JSON.stringify(options));
-};
-
-const updateListSetting = (evt) => {
- const options = JSON.parse(localStorage.getItem('weighted-settings'));
- const game = evt.target.getAttribute('data-game');
- const setting = evt.target.getAttribute('data-setting');
- const option = evt.target.getAttribute('data-option');
-
- if (evt.target.checked) {
- // If the option is to be enabled and it is already enabled, do nothing
- if (options[game][setting].includes(option)) { return; }
-
- options[game][setting].push(option);
- } else {
- // If the option is to be disabled and it is already disabled, do nothing
- if (!options[game][setting].includes(option)) { return; }
-
- options[game][setting].splice(options[game][setting].indexOf(option), 1);
- }
- localStorage.setItem('weighted-settings', JSON.stringify(options));
-};
-
-const updateItemSetting = (evt) => {
- const options = JSON.parse(localStorage.getItem('weighted-settings'));
- const game = evt.target.getAttribute('data-game');
- const setting = evt.target.getAttribute('data-setting');
- const option = evt.target.getAttribute('data-option');
- if (setting === 'start_inventory') {
- options[game][setting][option] = evt.target.value.trim() ? parseInt(evt.target.value) : 0;
- } else {
- options[game][setting][option] = isNaN(evt.target.value) ?
- evt.target.value : parseInt(evt.target.value, 10);
- }
- localStorage.setItem('weighted-settings', JSON.stringify(options));
-};
-
-const validateSettings = () => {
- const settings = JSON.parse(localStorage.getItem('weighted-settings'));
- const userMessage = document.getElementById('user-message');
- let errorMessage = null;
-
- // User must choose a name for their file
- if (!settings.name || settings.name.trim().length === 0 || settings.name.toLowerCase().trim() === 'player') {
- userMessage.innerText = 'You forgot to set your player name at the top of the page!';
- userMessage.classList.add('visible');
- userMessage.scrollIntoView({
- behavior: 'smooth',
- block: 'start',
- });
- return;
- }
-
- // Clean up the settings output
- Object.keys(settings.game).forEach((game) => {
- // Remove any disabled games
- if (settings.game[game] === 0) {
- delete settings.game[game];
- delete settings[game];
- return;
- }
-
- // Remove any disabled options
- Object.keys(settings[game]).forEach((setting) => {
- Object.keys(settings[game][setting]).forEach((option) => {
- if (settings[game][setting][option] === 0) {
- delete settings[game][setting][option];
- }
- });
-
- if (
- Object.keys(settings[game][setting]).length === 0 &&
- !Array.isArray(settings[game][setting]) &&
- setting !== 'start_inventory'
- ) {
- errorMessage = `${game} // ${setting} has no values above zero!`;
- }
- });
- });
-
- if (Object.keys(settings.game).length === 0) {
- errorMessage = 'You have not chosen a game to play!';
- }
-
- // If an error occurred, alert the user and do not export the file
- if (errorMessage) {
- userMessage.innerText = errorMessage;
- userMessage.classList.add('visible');
- userMessage.scrollIntoView({
- behavior: 'smooth',
- block: 'start',
- });
- return;
- }
-
- // If no error occurred, hide the user message if it is visible
- userMessage.classList.remove('visible');
- return settings;
-};
-
-const exportSettings = () => {
- const settings = validateSettings();
- if (!settings) { return; }
-
- const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
- download(`${document.getElementById('player-name').value}.yaml`, yamlText);
-};
-
-/** Create an anchor and trigger a download of a text file. */
-const download = (filename, text) => {
- const downloadLink = document.createElement('a');
- downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
- downloadLink.setAttribute('download', filename);
- downloadLink.style.display = 'none';
- document.body.appendChild(downloadLink);
- downloadLink.click();
- document.body.removeChild(downloadLink);
-};
-
-const generateGame = (raceMode = false) => {
- const settings = validateSettings();
- if (!settings) { return; }
-
- axios.post('/api/generate', {
- weights: { player: JSON.stringify(settings) },
- presetData: { player: JSON.stringify(settings) },
- playerCount: 1,
- spoiler: 3,
- race: raceMode ? '1' : '0',
- }).then((response) => {
- window.location.href = response.data.url;
- }).catch((error) => {
- const userMessage = document.getElementById('user-message');
- userMessage.innerText = 'Something went wrong and your game could not be generated.';
- if (error.response.data.text) {
- userMessage.innerText += ' ' + error.response.data.text;
- }
- userMessage.classList.add('visible');
- userMessage.scrollIntoView({
- behavior: 'smooth',
- block: 'start',
- });
- console.error(error);
- });
-};
diff --git a/WebHostLib/static/assets/weightedOptions.js b/WebHostLib/static/assets/weightedOptions.js
new file mode 100644
index 000000000000..0417ab174b0e
--- /dev/null
+++ b/WebHostLib/static/assets/weightedOptions.js
@@ -0,0 +1,223 @@
+let deletedOptions = {};
+
+window.addEventListener('load', () => {
+ const worldName = document.querySelector('#weighted-options').getAttribute('data-game');
+
+ // Generic change listener. Detecting unique qualities and acting on them here reduces initial JS initialisation time
+ // and handles dynamically created elements
+ document.addEventListener('change', (evt) => {
+ // Handle updates to range inputs
+ if (evt.target.type === 'range') {
+ // Update span containing range value. All ranges have a corresponding `{rangeId}-value` span
+ document.getElementById(`${evt.target.id}-value`).innerText = evt.target.value;
+
+ // If the changed option was the name of a game, determine whether to show or hide that game's div
+ if (evt.target.id.startsWith('game||')) {
+ const gameName = evt.target.id.split('||')[1];
+ const gameDiv = document.getElementById(`${gameName}-container`);
+ if (evt.target.value > 0) {
+ gameDiv.classList.remove('hidden');
+ } else {
+ gameDiv.classList.add('hidden');
+ }
+ }
+ }
+ });
+
+ // Generic click listener
+ document.addEventListener('click', (evt) => {
+ // Handle creating new rows for Range options
+ if (evt.target.classList.contains('add-range-option-button')) {
+ const optionName = evt.target.getAttribute('data-option');
+ addRangeRow(optionName);
+ }
+
+ // Handle deleting range rows
+ if (evt.target.classList.contains('range-option-delete')) {
+ const targetRow = document.querySelector(`tr[data-row="${evt.target.getAttribute('data-target')}"]`);
+ setDeletedOption(
+ targetRow.getAttribute('data-option-name'),
+ targetRow.getAttribute('data-value'),
+ );
+ targetRow.parentElement.removeChild(targetRow);
+ }
+ });
+
+ // Listen for enter presses on inputs intended to add range rows
+ document.addEventListener('keydown', (evt) => {
+ if (evt.key === 'Enter') {
+ evt.preventDefault();
+ }
+
+ if (evt.key === 'Enter' && evt.target.classList.contains('range-option-value')) {
+ const optionName = evt.target.getAttribute('data-option');
+ addRangeRow(optionName);
+ }
+ });
+
+ // Detect form submission
+ document.getElementById('weighted-options-form').addEventListener('submit', (evt) => {
+ // Save data to localStorage
+ const weightedOptions = {};
+ document.querySelectorAll('input[name]').forEach((input) => {
+ const keys = input.getAttribute('name').split('||');
+
+ // Determine keys
+ const optionName = keys[0] ?? null;
+ const subOption = keys[1] ?? null;
+
+ // Ensure keys exist
+ if (!weightedOptions[optionName]) { weightedOptions[optionName] = {}; }
+ if (subOption && !weightedOptions[optionName][subOption]) {
+ weightedOptions[optionName][subOption] = null;
+ }
+
+ if (subOption) { return weightedOptions[optionName][subOption] = determineValue(input); }
+ if (optionName) { return weightedOptions[optionName] = determineValue(input); }
+ });
+
+ localStorage.setItem(`${worldName}-weights`, JSON.stringify(weightedOptions));
+ localStorage.setItem(`${worldName}-deletedOptions`, JSON.stringify(deletedOptions));
+ });
+
+ // Remove all deleted values as specified by localStorage
+ deletedOptions = JSON.parse(localStorage.getItem(`${worldName}-deletedOptions`) || '{}');
+ Object.keys(deletedOptions).forEach((optionName) => {
+ deletedOptions[optionName].forEach((value) => {
+ const targetRow = document.querySelector(`tr[data-row="${value}-row"]`);
+ targetRow.parentElement.removeChild(targetRow);
+ });
+ });
+
+ // Populate all settings from localStorage on page initialisation
+ const previousSettingsJson = localStorage.getItem(`${worldName}-weights`);
+ if (previousSettingsJson) {
+ const previousSettings = JSON.parse(previousSettingsJson);
+ Object.keys(previousSettings).forEach((option) => {
+ if (typeof previousSettings[option] === 'string') {
+ return document.querySelector(`input[name="${option}"]`).value = previousSettings[option];
+ }
+
+ Object.keys(previousSettings[option]).forEach((value) => {
+ const input = document.querySelector(`input[name="${option}||${value}"]`);
+ if (!input?.type) {
+ return console.error(`Unable to populate option with name ${option}||${value}.`);
+ }
+
+ switch (input.type) {
+ case 'checkbox':
+ input.checked = (parseInt(previousSettings[option][value], 10) === 1);
+ break;
+ case 'range':
+ input.value = parseInt(previousSettings[option][value], 10);
+ break;
+ case 'number':
+ input.value = previousSettings[option][value].toString();
+ break;
+ default:
+ console.error(`Found unsupported input type: ${input.type}`);
+ }
+ });
+ });
+ }
+});
+
+const addRangeRow = (optionName) => {
+ const inputQuery = `input[type=number][data-option="${optionName}"].range-option-value`;
+ const inputTarget = document.querySelector(inputQuery);
+ const newValue = inputTarget.value;
+ if (!/^-?\d+$/.test(newValue)) {
+ alert('Range values must be a positive or negative integer!');
+ return;
+ }
+ inputTarget.value = '';
+ const tBody = document.querySelector(`table[data-option="${optionName}"].range-rows tbody`);
+ const tr = document.createElement('tr');
+ tr.setAttribute('data-row', `${optionName}-${newValue}-row`);
+ tr.setAttribute('data-option-name', optionName);
+ tr.setAttribute('data-value', newValue);
+ const tdLeft = document.createElement('td');
+ tdLeft.classList.add('td-left');
+ const label = document.createElement('label');
+ label.setAttribute('for', `${optionName}||${newValue}`);
+ label.innerText = newValue.toString();
+ tdLeft.appendChild(label);
+ tr.appendChild(tdLeft);
+ const tdMiddle = document.createElement('td');
+ tdMiddle.classList.add('td-middle');
+ const range = document.createElement('input');
+ range.setAttribute('type', 'range');
+ range.setAttribute('min', '0');
+ range.setAttribute('max', '50');
+ range.setAttribute('value', '0');
+ range.setAttribute('id', `${optionName}||${newValue}`);
+ range.setAttribute('name', `${optionName}||${newValue}`);
+ tdMiddle.appendChild(range);
+ tr.appendChild(tdMiddle);
+ const tdRight = document.createElement('td');
+ tdRight.classList.add('td-right');
+ const valueSpan = document.createElement('span');
+ valueSpan.setAttribute('id', `${optionName}||${newValue}-value`);
+ valueSpan.innerText = '0';
+ tdRight.appendChild(valueSpan);
+ tr.appendChild(tdRight);
+ const tdDelete = document.createElement('td');
+ const deleteSpan = document.createElement('span');
+ deleteSpan.classList.add('range-option-delete');
+ deleteSpan.classList.add('js-required');
+ deleteSpan.setAttribute('data-target', `${optionName}-${newValue}-row`);
+ deleteSpan.innerText = 'â';
+ tdDelete.appendChild(deleteSpan);
+ tr.appendChild(tdDelete);
+ tBody.appendChild(tr);
+
+ // Remove this option from the set of deleted options if it exists
+ unsetDeletedOption(optionName, newValue);
+};
+
+/**
+ * Determines the value of an input element, or returns a 1 or 0 if the element is a checkbox
+ *
+ * @param {object} input - The input element.
+ * @returns {number} The value of the input element.
+ */
+const determineValue = (input) => {
+ switch (input.type) {
+ case 'checkbox':
+ return (input.checked ? 1 : 0);
+ case 'range':
+ return parseInt(input.value, 10);
+ default:
+ return input.value;
+ }
+};
+
+/**
+ * Sets the deleted option value for a given world and option name.
+ * If the world or option does not exist, it creates the necessary entries.
+ *
+ * @param {string} optionName - The name of the option.
+ * @param {*} value - The value to be set for the deleted option.
+ * @returns {void}
+ */
+const setDeletedOption = (optionName, value) => {
+ deletedOptions[optionName] = deletedOptions[optionName] || [];
+ deletedOptions[optionName].push(`${optionName}-${value}`);
+};
+
+/**
+ * Removes a specific value from the deletedOptions object.
+ *
+ * @param {string} optionName - The name of the option.
+ * @param {*} value - The value to be removed
+ * @returns {void}
+ */
+const unsetDeletedOption = (optionName, value) => {
+ if (!deletedOptions.hasOwnProperty(optionName)) { return; }
+ if (deletedOptions[optionName].includes(`${optionName}-${value}`)) {
+ deletedOptions[optionName].splice(deletedOptions[optionName].indexOf(`${optionName}-${value}`), 1);
+ }
+ if (deletedOptions[optionName].length === 0) {
+ delete deletedOptions[optionName];
+ }
+};
diff --git a/WebHostLib/static/robots_file.txt b/WebHostLib/static/robots_file.txt
new file mode 100644
index 000000000000..770ae26c1985
--- /dev/null
+++ b/WebHostLib/static/robots_file.txt
@@ -0,0 +1,20 @@
+User-agent: Googlebot
+Disallow: /
+
+User-agent: APIs-Google
+Disallow: /
+
+User-agent: AdsBot-Google-Mobile
+Disallow: /
+
+User-agent: AdsBot-Google-Mobile
+Disallow: /
+
+User-agent: Mediapartners-Google
+Disallow: /
+
+User-agent: Google-Safety
+Disallow: /
+
+User-agent: *
+Disallow: /
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-atlas.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-atlas.png
new file mode 100644
index 000000000000..537e27979180
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-atlas.png differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-atlas.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-atlas.webp
new file mode 100644
index 000000000000..f34cd5ff2ec1
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-atlas.webp differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.png
index 326670b7ebc4..a0b41b0f8cac 100644
Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.png differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.webp
new file mode 100644
index 000000000000..4a5f2d75a0d4
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-left-corner.webp differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.png
index c8297d34578c..6e1608d82b7f 100644
Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.png differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.webp
new file mode 100644
index 000000000000..30bd2d047a76
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom-right-corner.webp differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.png
index 2a28958e0931..3d3e089ef79f 100644
Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.png differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.webp
new file mode 100644
index 000000000000..f575ac5d9d48
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-bottom.webp differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.png
index 9bc84ff603ec..08730d98489c 100644
Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.png differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.webp
new file mode 100644
index 000000000000..f9227e8f2286
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-left.webp differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.png
index a1e9c7c8b6f5..0bc82fa70e9b 100644
Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.png differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.webp
new file mode 100644
index 000000000000..3c0a57740263
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-right.webp differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.png
index a40bca60f080..05e675d6a97c 100644
Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.png differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.webp
new file mode 100644
index 000000000000..4283cd42b16a
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-left-corner.webp differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.png
index b8a8c6a7265e..e0683a74bba5 100644
Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.png differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.webp
new file mode 100644
index 000000000000..3075cec96add
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top-right-corner.webp differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.png b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.png
index bb6ccec3d583..cded7ad108d3 100644
Binary files a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.png and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.png differ
diff --git a/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.webp b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.webp
new file mode 100644
index 000000000000..781b8e4df0d0
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/cliffs/grass/cliff-top.webp differ
diff --git a/WebHostLib/static/static/backgrounds/clouds/cloud-0001.png b/WebHostLib/static/static/backgrounds/clouds/cloud-0001.png
index dba338f58552..1015819bc8f6 100644
Binary files a/WebHostLib/static/static/backgrounds/clouds/cloud-0001.png and b/WebHostLib/static/static/backgrounds/clouds/cloud-0001.png differ
diff --git a/WebHostLib/static/static/backgrounds/clouds/cloud-0001.webp b/WebHostLib/static/static/backgrounds/clouds/cloud-0001.webp
new file mode 100644
index 000000000000..73e249f6e530
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/clouds/cloud-0001.webp differ
diff --git a/WebHostLib/static/static/backgrounds/clouds/cloud-0002.png b/WebHostLib/static/static/backgrounds/clouds/cloud-0002.png
index 33f09b19ce86..7b479bfe7b0b 100644
Binary files a/WebHostLib/static/static/backgrounds/clouds/cloud-0002.png and b/WebHostLib/static/static/backgrounds/clouds/cloud-0002.png differ
diff --git a/WebHostLib/static/static/backgrounds/clouds/cloud-0002.webp b/WebHostLib/static/static/backgrounds/clouds/cloud-0002.webp
new file mode 100644
index 000000000000..e4ac19bef687
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/clouds/cloud-0002.webp differ
diff --git a/WebHostLib/static/static/backgrounds/clouds/cloud-0003.png b/WebHostLib/static/static/backgrounds/clouds/cloud-0003.png
index f665015b0d01..59844e31ac42 100644
Binary files a/WebHostLib/static/static/backgrounds/clouds/cloud-0003.png and b/WebHostLib/static/static/backgrounds/clouds/cloud-0003.png differ
diff --git a/WebHostLib/static/static/backgrounds/clouds/cloud-0003.webp b/WebHostLib/static/static/backgrounds/clouds/cloud-0003.webp
new file mode 100644
index 000000000000..36abe6e552a3
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/clouds/cloud-0003.webp differ
diff --git a/WebHostLib/static/static/backgrounds/dirt.png b/WebHostLib/static/static/backgrounds/dirt.png
index 4ac930edc698..db6bc34635e3 100644
Binary files a/WebHostLib/static/static/backgrounds/dirt.png and b/WebHostLib/static/static/backgrounds/dirt.png differ
diff --git a/WebHostLib/static/static/backgrounds/dirt.webp b/WebHostLib/static/static/backgrounds/dirt.webp
new file mode 100644
index 000000000000..5a8635506f9f
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/dirt.webp differ
diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0001.png b/WebHostLib/static/static/backgrounds/footer/footer-0001.png
index b863a3d42952..6752ab4e3279 100644
Binary files a/WebHostLib/static/static/backgrounds/footer/footer-0001.png and b/WebHostLib/static/static/backgrounds/footer/footer-0001.png differ
diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0001.webp b/WebHostLib/static/static/backgrounds/footer/footer-0001.webp
new file mode 100644
index 000000000000..fb278c3b1643
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/footer/footer-0001.webp differ
diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0002.png b/WebHostLib/static/static/backgrounds/footer/footer-0002.png
index 90fdfe95d015..3bacab4134e2 100644
Binary files a/WebHostLib/static/static/backgrounds/footer/footer-0002.png and b/WebHostLib/static/static/backgrounds/footer/footer-0002.png differ
diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0002.webp b/WebHostLib/static/static/backgrounds/footer/footer-0002.webp
new file mode 100644
index 000000000000..9b8e457c52a9
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/footer/footer-0002.webp differ
diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0003.png b/WebHostLib/static/static/backgrounds/footer/footer-0003.png
index 5fc31d1ee970..f8223e690171 100644
Binary files a/WebHostLib/static/static/backgrounds/footer/footer-0003.png and b/WebHostLib/static/static/backgrounds/footer/footer-0003.png differ
diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0003.webp b/WebHostLib/static/static/backgrounds/footer/footer-0003.webp
new file mode 100644
index 000000000000..c2ded77536d6
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/footer/footer-0003.webp differ
diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0004.png b/WebHostLib/static/static/backgrounds/footer/footer-0004.png
index 4a95ce9a3aaf..d4476e53f759 100644
Binary files a/WebHostLib/static/static/backgrounds/footer/footer-0004.png and b/WebHostLib/static/static/backgrounds/footer/footer-0004.png differ
diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0004.webp b/WebHostLib/static/static/backgrounds/footer/footer-0004.webp
new file mode 100644
index 000000000000..a2100817461a
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/footer/footer-0004.webp differ
diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0005.png b/WebHostLib/static/static/backgrounds/footer/footer-0005.png
index 7b7cd502f36c..794615962454 100644
Binary files a/WebHostLib/static/static/backgrounds/footer/footer-0005.png and b/WebHostLib/static/static/backgrounds/footer/footer-0005.png differ
diff --git a/WebHostLib/static/static/backgrounds/footer/footer-0005.webp b/WebHostLib/static/static/backgrounds/footer/footer-0005.webp
new file mode 100644
index 000000000000..c0ee5205ca22
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/footer/footer-0005.webp differ
diff --git a/WebHostLib/static/static/backgrounds/grass-flowers.png b/WebHostLib/static/static/backgrounds/grass-flowers.png
index 464fdbe58155..ea39c5419004 100644
Binary files a/WebHostLib/static/static/backgrounds/grass-flowers.png and b/WebHostLib/static/static/backgrounds/grass-flowers.png differ
diff --git a/WebHostLib/static/static/backgrounds/grass-flowers.webp b/WebHostLib/static/static/backgrounds/grass-flowers.webp
new file mode 100644
index 000000000000..1b8ebd7706ad
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/grass-flowers.webp differ
diff --git a/WebHostLib/static/static/backgrounds/grass.png b/WebHostLib/static/static/backgrounds/grass.png
index b88c33dec44b..6a99c4d94310 100644
Binary files a/WebHostLib/static/static/backgrounds/grass.png and b/WebHostLib/static/static/backgrounds/grass.png differ
diff --git a/WebHostLib/static/static/backgrounds/grass.webp b/WebHostLib/static/static/backgrounds/grass.webp
new file mode 100644
index 000000000000..212ab377a624
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/grass.webp differ
diff --git a/WebHostLib/static/static/backgrounds/header/dirt-header.png b/WebHostLib/static/static/backgrounds/header/dirt-header.png
index 7c9e298e228b..8a9c0963e72f 100644
Binary files a/WebHostLib/static/static/backgrounds/header/dirt-header.png and b/WebHostLib/static/static/backgrounds/header/dirt-header.png differ
diff --git a/WebHostLib/static/static/backgrounds/header/dirt-header.webp b/WebHostLib/static/static/backgrounds/header/dirt-header.webp
new file mode 100644
index 000000000000..6c2b0bd8bf9b
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/header/dirt-header.webp differ
diff --git a/WebHostLib/static/static/backgrounds/header/grass-header.png b/WebHostLib/static/static/backgrounds/header/grass-header.png
index c2acc588071c..6d620e5033a2 100644
Binary files a/WebHostLib/static/static/backgrounds/header/grass-header.png and b/WebHostLib/static/static/backgrounds/header/grass-header.png differ
diff --git a/WebHostLib/static/static/backgrounds/header/grass-header.webp b/WebHostLib/static/static/backgrounds/header/grass-header.webp
new file mode 100644
index 000000000000..ca5d1e23bc2b
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/header/grass-header.webp differ
diff --git a/WebHostLib/static/static/backgrounds/header/ocean-header.png b/WebHostLib/static/static/backgrounds/header/ocean-header.png
index a0ff51f924f8..1e1c18e93c65 100644
Binary files a/WebHostLib/static/static/backgrounds/header/ocean-header.png and b/WebHostLib/static/static/backgrounds/header/ocean-header.png differ
diff --git a/WebHostLib/static/static/backgrounds/header/ocean-header.webp b/WebHostLib/static/static/backgrounds/header/ocean-header.webp
new file mode 100644
index 000000000000..fc1803ca0e4b
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/header/ocean-header.webp differ
diff --git a/WebHostLib/static/static/backgrounds/header/party-time-header.png b/WebHostLib/static/static/backgrounds/header/party-time-header.png
index 799f32f2282e..601ad829f1fa 100644
Binary files a/WebHostLib/static/static/backgrounds/header/party-time-header.png and b/WebHostLib/static/static/backgrounds/header/party-time-header.png differ
diff --git a/WebHostLib/static/static/backgrounds/header/party-time-header.webp b/WebHostLib/static/static/backgrounds/header/party-time-header.webp
new file mode 100644
index 000000000000..0b3c70871ada
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/header/party-time-header.webp differ
diff --git a/WebHostLib/static/static/backgrounds/header/stone-header.png b/WebHostLib/static/static/backgrounds/header/stone-header.png
index e0c9787e5735..f0d2f2fee56e 100644
Binary files a/WebHostLib/static/static/backgrounds/header/stone-header.png and b/WebHostLib/static/static/backgrounds/header/stone-header.png differ
diff --git a/WebHostLib/static/static/backgrounds/header/stone-header.webp b/WebHostLib/static/static/backgrounds/header/stone-header.webp
new file mode 100644
index 000000000000..9f26d1a505d4
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/header/stone-header.webp differ
diff --git a/WebHostLib/static/static/backgrounds/ice.png b/WebHostLib/static/static/backgrounds/ice.png
index fcf7299b3582..c64f1b20f3b0 100644
Binary files a/WebHostLib/static/static/backgrounds/ice.png and b/WebHostLib/static/static/backgrounds/ice.png differ
diff --git a/WebHostLib/static/static/backgrounds/ice.webp b/WebHostLib/static/static/backgrounds/ice.webp
new file mode 100644
index 000000000000..a129d5f439c6
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/ice.webp differ
diff --git a/WebHostLib/static/static/backgrounds/jungle.png b/WebHostLib/static/static/backgrounds/jungle.png
index e27d7e992086..c4ec5b964847 100644
Binary files a/WebHostLib/static/static/backgrounds/jungle.png and b/WebHostLib/static/static/backgrounds/jungle.png differ
diff --git a/WebHostLib/static/static/backgrounds/jungle.webp b/WebHostLib/static/static/backgrounds/jungle.webp
new file mode 100644
index 000000000000..d21edc8e55f2
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/jungle.webp differ
diff --git a/WebHostLib/static/static/backgrounds/ocean.png b/WebHostLib/static/static/backgrounds/ocean.png
index 5c22c0b92aa1..d6c9d285c963 100644
Binary files a/WebHostLib/static/static/backgrounds/ocean.png and b/WebHostLib/static/static/backgrounds/ocean.png differ
diff --git a/WebHostLib/static/static/backgrounds/ocean.webp b/WebHostLib/static/static/backgrounds/ocean.webp
new file mode 100644
index 000000000000..a50b7b27f743
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/ocean.webp differ
diff --git a/WebHostLib/static/static/backgrounds/party-time.png b/WebHostLib/static/static/backgrounds/party-time.png
index ad00851ba4dc..3fcea8a46eef 100644
Binary files a/WebHostLib/static/static/backgrounds/party-time.png and b/WebHostLib/static/static/backgrounds/party-time.png differ
diff --git a/WebHostLib/static/static/backgrounds/party-time.webp b/WebHostLib/static/static/backgrounds/party-time.webp
new file mode 100644
index 000000000000..7cd547329a40
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/party-time.webp differ
diff --git a/WebHostLib/static/static/backgrounds/stone.png b/WebHostLib/static/static/backgrounds/stone.png
index 9e15a34375e4..2956beaaa80b 100644
Binary files a/WebHostLib/static/static/backgrounds/stone.png and b/WebHostLib/static/static/backgrounds/stone.png differ
diff --git a/WebHostLib/static/static/backgrounds/stone.webp b/WebHostLib/static/static/backgrounds/stone.webp
new file mode 100644
index 000000000000..96303c816227
Binary files /dev/null and b/WebHostLib/static/static/backgrounds/stone.webp differ
diff --git a/WebHostLib/static/static/branding/header-logo-full.svg b/WebHostLib/static/static/branding/header-logo-full.svg
new file mode 100644
index 000000000000..3e22500905f3
--- /dev/null
+++ b/WebHostLib/static/static/branding/header-logo-full.svg
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/WebHostLib/static/static/branding/header-logo.png b/WebHostLib/static/static/branding/header-logo.png
index e5d7f9b4a0c0..5a3dbe7dafc5 100644
Binary files a/WebHostLib/static/static/branding/header-logo.png and b/WebHostLib/static/static/branding/header-logo.png differ
diff --git a/WebHostLib/static/static/branding/header-logo.svg b/WebHostLib/static/static/branding/header-logo.svg
index 3e22500905f3..ceedba43385a 100644
--- a/WebHostLib/static/static/branding/header-logo.svg
+++ b/WebHostLib/static/static/branding/header-logo.svg
@@ -1,66 +1 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/WebHostLib/static/static/branding/header-logo.webp b/WebHostLib/static/static/branding/header-logo.webp
new file mode 100644
index 000000000000..c8088e826266
Binary files /dev/null and b/WebHostLib/static/static/branding/header-logo.webp differ
diff --git a/WebHostLib/static/static/branding/landing-logo.png b/WebHostLib/static/static/branding/landing-logo.png
index 1f2b967a9844..d4845a475daa 100644
Binary files a/WebHostLib/static/static/branding/landing-logo.png and b/WebHostLib/static/static/branding/landing-logo.png differ
diff --git a/WebHostLib/static/static/branding/landing-logo.webp b/WebHostLib/static/static/branding/landing-logo.webp
new file mode 100644
index 000000000000..7bd4673e99e0
Binary files /dev/null and b/WebHostLib/static/static/branding/landing-logo.webp differ
diff --git a/WebHostLib/static/static/button-images/hamburger-menu-icon.png b/WebHostLib/static/static/button-images/hamburger-menu-icon.png
index f1c96316358d..c834501453ab 100644
Binary files a/WebHostLib/static/static/button-images/hamburger-menu-icon.png and b/WebHostLib/static/static/button-images/hamburger-menu-icon.png differ
diff --git a/WebHostLib/static/static/button-images/hamburger-menu-icon.webp b/WebHostLib/static/static/button-images/hamburger-menu-icon.webp
new file mode 100644
index 000000000000..970754d7bfc8
Binary files /dev/null and b/WebHostLib/static/static/button-images/hamburger-menu-icon.webp differ
diff --git a/WebHostLib/static/static/button-images/island-button-a.png b/WebHostLib/static/static/button-images/island-button-a.png
index f3872dfd6cdf..552e4d8f6d34 100644
Binary files a/WebHostLib/static/static/button-images/island-button-a.png and b/WebHostLib/static/static/button-images/island-button-a.png differ
diff --git a/WebHostLib/static/static/button-images/island-button-a.webp b/WebHostLib/static/static/button-images/island-button-a.webp
new file mode 100644
index 000000000000..6da0c1720030
Binary files /dev/null and b/WebHostLib/static/static/button-images/island-button-a.webp differ
diff --git a/WebHostLib/static/static/button-images/island-button-b.png b/WebHostLib/static/static/button-images/island-button-b.png
index 65008eaf59ef..fd4a256c7c9f 100644
Binary files a/WebHostLib/static/static/button-images/island-button-b.png and b/WebHostLib/static/static/button-images/island-button-b.png differ
diff --git a/WebHostLib/static/static/button-images/island-button-b.webp b/WebHostLib/static/static/button-images/island-button-b.webp
new file mode 100644
index 000000000000..6b7c3a279ed0
Binary files /dev/null and b/WebHostLib/static/static/button-images/island-button-b.webp differ
diff --git a/WebHostLib/static/static/button-images/island-button-c.png b/WebHostLib/static/static/button-images/island-button-c.png
index 9e5f9f50d2be..2f10f45828c4 100644
Binary files a/WebHostLib/static/static/button-images/island-button-c.png and b/WebHostLib/static/static/button-images/island-button-c.png differ
diff --git a/WebHostLib/static/static/button-images/island-button-c.webp b/WebHostLib/static/static/button-images/island-button-c.webp
new file mode 100644
index 000000000000..83ce413da807
Binary files /dev/null and b/WebHostLib/static/static/button-images/island-button-c.webp differ
diff --git a/WebHostLib/static/static/button-images/popover.png b/WebHostLib/static/static/button-images/popover.png
index cbc863410489..e3247194b06b 100644
Binary files a/WebHostLib/static/static/button-images/popover.png and b/WebHostLib/static/static/button-images/popover.png differ
diff --git a/WebHostLib/static/static/button-images/popover.webp b/WebHostLib/static/static/button-images/popover.webp
new file mode 100644
index 000000000000..cd1c006221b0
Binary files /dev/null and b/WebHostLib/static/static/button-images/popover.webp differ
diff --git a/WebHostLib/static/static/decorations/island-a.png b/WebHostLib/static/static/decorations/island-a.png
index d931aed0bdc7..4f5d7c264198 100644
Binary files a/WebHostLib/static/static/decorations/island-a.png and b/WebHostLib/static/static/decorations/island-a.png differ
diff --git a/WebHostLib/static/static/decorations/island-a.webp b/WebHostLib/static/static/decorations/island-a.webp
new file mode 100644
index 000000000000..32c9cc8f6bd6
Binary files /dev/null and b/WebHostLib/static/static/decorations/island-a.webp differ
diff --git a/WebHostLib/static/static/decorations/island-b.png b/WebHostLib/static/static/decorations/island-b.png
index d6902281922c..cceb79af33b0 100644
Binary files a/WebHostLib/static/static/decorations/island-b.png and b/WebHostLib/static/static/decorations/island-b.png differ
diff --git a/WebHostLib/static/static/decorations/island-b.webp b/WebHostLib/static/static/decorations/island-b.webp
new file mode 100644
index 000000000000..3ec6aae438ba
Binary files /dev/null and b/WebHostLib/static/static/decorations/island-b.webp differ
diff --git a/WebHostLib/static/static/decorations/island-c.png b/WebHostLib/static/static/decorations/island-c.png
index 790c7b01d53c..2beedce19d26 100644
Binary files a/WebHostLib/static/static/decorations/island-c.png and b/WebHostLib/static/static/decorations/island-c.png differ
diff --git a/WebHostLib/static/static/decorations/island-c.webp b/WebHostLib/static/static/decorations/island-c.webp
new file mode 100644
index 000000000000..98e1add91ee3
Binary files /dev/null and b/WebHostLib/static/static/decorations/island-c.webp differ
diff --git a/WebHostLib/static/static/decorations/rock-in-water.png b/WebHostLib/static/static/decorations/rock-in-water.png
index 25c62acd24fb..1320bef7cee1 100644
Binary files a/WebHostLib/static/static/decorations/rock-in-water.png and b/WebHostLib/static/static/decorations/rock-in-water.png differ
diff --git a/WebHostLib/static/static/decorations/rock-in-water.webp b/WebHostLib/static/static/decorations/rock-in-water.webp
new file mode 100644
index 000000000000..2c8af460d5e2
Binary files /dev/null and b/WebHostLib/static/static/decorations/rock-in-water.webp differ
diff --git a/WebHostLib/static/static/decorations/rock-single.png b/WebHostLib/static/static/decorations/rock-single.png
index cc237d132ef4..c003abe0d173 100644
Binary files a/WebHostLib/static/static/decorations/rock-single.png and b/WebHostLib/static/static/decorations/rock-single.png differ
diff --git a/WebHostLib/static/static/decorations/rock-single.webp b/WebHostLib/static/static/decorations/rock-single.webp
new file mode 100644
index 000000000000..e53a2fb5c480
Binary files /dev/null and b/WebHostLib/static/static/decorations/rock-single.webp differ
diff --git a/WebHostLib/static/styles/globalStyles.css b/WebHostLib/static/styles/globalStyles.css
index a787b0c6570a..adcee6581be4 100644
--- a/WebHostLib/static/styles/globalStyles.css
+++ b/WebHostLib/static/styles/globalStyles.css
@@ -36,6 +36,13 @@ html{
body{
margin: 0;
+ display: flex;
+ flex-direction: column;
+ min-height: calc(100vh - 110px);
+}
+
+main {
+ flex-grow: 1;
}
a{
@@ -44,7 +51,7 @@ a{
font-family: LexendDeca-Regular, sans-serif;
}
-button{
+button, input[type=submit]{
font-weight: 500;
font-size: 0.9rem;
padding: 10px 17px 11px 16px; /* top right bottom left */
@@ -57,7 +64,7 @@ button{
cursor: pointer;
}
-button:active{
+button:active, input[type=submit]:active{
border-right: 1px solid rgba(0, 0, 0, 0.5);
border-bottom: 1px solid rgba(0, 0, 0, 0.5);
padding-right: 16px;
@@ -66,11 +73,11 @@ button:active{
margin-bottom: 2px;
}
-button.button-grass{
+button.button-grass, input[type=submit].button-grass{
border: 1px solid black;
}
-button.button-dirt{
+button.button-dirt, input[type=submit].button-dirt{
border: 1px solid black;
}
@@ -111,4 +118,4 @@ h5, h6{
.interactive{
color: #ffef00;
-}
\ No newline at end of file
+}
diff --git a/WebHostLib/static/styles/hostRoom.css b/WebHostLib/static/styles/hostRoom.css
index 827f74c04df7..625b78cc5d3f 100644
--- a/WebHostLib/static/styles/hostRoom.css
+++ b/WebHostLib/static/styles/hostRoom.css
@@ -58,3 +58,28 @@
overflow-y: auto;
max-height: 400px;
}
+
+.loader{
+ display: inline-block;
+ visibility: hidden;
+ margin-left: 5px;
+ width: 40px;
+ aspect-ratio: 4;
+ --_g: no-repeat radial-gradient(circle closest-side,#fff 90%,#fff0);
+ background:
+ var(--_g) 0 50%,
+ var(--_g) 50% 50%,
+ var(--_g) 100% 50%;
+ background-size: calc(100%/3) 100%;
+ animation: l7 1s infinite linear;
+}
+
+.loader.loading{
+ visibility: visible;
+}
+
+@keyframes l7{
+ 33%{background-size:calc(100%/3) 0% ,calc(100%/3) 100%,calc(100%/3) 100%}
+ 50%{background-size:calc(100%/3) 100%,calc(100%/3) 0 ,calc(100%/3) 100%}
+ 66%{background-size:calc(100%/3) 100%,calc(100%/3) 100%,calc(100%/3) 0 }
+}
diff --git a/WebHostLib/static/styles/landing.css b/WebHostLib/static/styles/landing.css
index 202c43badd5f..96975553c142 100644
--- a/WebHostLib/static/styles/landing.css
+++ b/WebHostLib/static/styles/landing.css
@@ -235,9 +235,6 @@ html{
line-height: 30px;
}
-#landing .variable{
- color: #ffff00;
-}
.landing-deco{
position: absolute;
diff --git a/WebHostLib/static/styles/lttp-tracker.css b/WebHostLib/static/styles/lttp-tracker.css
deleted file mode 100644
index 899a8f695925..000000000000
--- a/WebHostLib/static/styles/lttp-tracker.css
+++ /dev/null
@@ -1,75 +0,0 @@
-#player-tracker-wrapper{
- margin: 0;
- font-family: LexendDeca-Light, sans-serif;
- color: white;
- font-size: 14px;
-}
-
-#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: 284px;
- background-color: #42b149;
-}
-
-#inventory-table td{
- width: 40px;
- height: 40px;
- text-align: center;
- vertical-align: middle;
-}
-
-#inventory-table img{
- height: 100%;
- max-width: 40px;
- max-height: 40px;
- filter: grayscale(100%) contrast(75%) brightness(75%);
-}
-
-#inventory-table img.acquired{
- filter: none;
-}
-
-#inventory-table img.powder-fix{
- width: 35px;
- height: 35px;
-}
-
-#location-table{
- width: 284px;
- border-left: 2px solid #000000;
- border-right: 2px solid #000000;
- border-bottom: 2px solid #000000;
- border-bottom-left-radius: 4px;
- border-bottom-right-radius: 4px;
- background-color: #42b149;
- padding: 0 3px 3px;
-}
-
-#location-table th{
- vertical-align: middle;
- text-align: center;
- padding-right: 10px;
-}
-
-#location-table td{
- padding-top: 2px;
- padding-bottom: 2px;
- padding-right: 5px;
- line-height: 20px;
-}
-
-#location-table td.counter{
- padding-right: 8px;
- text-align: right;
-}
-
-#location-table img{
- height: 100%;
- max-width: 30px;
- max-height: 30px;
-}
diff --git a/WebHostLib/static/styles/markdown.css b/WebHostLib/static/styles/markdown.css
index dce135588e5f..ac06dea59d13 100644
--- a/WebHostLib/static/styles/markdown.css
+++ b/WebHostLib/static/styles/markdown.css
@@ -23,21 +23,19 @@
.markdown a{}
-.markdown h1{
+.markdown h1, .markdown details summary.h1{
font-size: 52px;
font-weight: normal;
font-family: LondrinaSolid-Regular, sans-serif;
text-transform: uppercase;
- cursor: pointer;
width: 100%;
text-shadow: 1px 1px 4px #000000;
}
-.markdown h2{
+.markdown h2, .markdown details summary.h2{
font-size: 38px;
font-weight: normal;
font-family: LondrinaSolid-Light, sans-serif;
- cursor: pointer;
width: 100%;
margin-top: 20px;
margin-bottom: 0.5rem;
@@ -45,42 +43,47 @@
text-shadow: 1px 1px 2px #000000;
}
-.markdown h3{
+.markdown h3, .markdown details summary.h3{
font-size: 26px;
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
text-align: left;
- cursor: pointer;
width: 100%;
margin-bottom: 0.5rem;
}
-.markdown h4{
+.markdown h4, .markdown details summary.h4{
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 24px;
- cursor: pointer;
margin-bottom: 24px;
}
-.markdown h5{
+.markdown h5, .markdown details summary.h5{
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 22px;
- cursor: pointer;
}
-.markdown h6{
+.markdown h6, .markdown details summary.h6{
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 20px;
- cursor: pointer;;
}
-.markdown h4, .markdown h5,.markdown h6{
+.markdown h4, .markdown h5, .markdown h6{
margin-bottom: 0.5rem;
}
+.markdown h1 > a,
+.markdown h2 > a,
+.markdown h3 > a,
+.markdown h4 > a,
+.markdown h5 > a,
+.markdown h6 > a {
+ color: inherit;
+}
+
.markdown ul{
margin-top: 0.5rem;
margin-bottom: 0.5rem;
diff --git a/WebHostLib/static/styles/minecraftTracker.css b/WebHostLib/static/styles/minecraftTracker.css
deleted file mode 100644
index 224cdcdc55a0..000000000000
--- a/WebHostLib/static/styles/minecraftTracker.css
+++ /dev/null
@@ -1,102 +0,0 @@
-#player-tracker-wrapper{
- margin: 0;
-}
-
-#inventory-table{
- border-top: 2px solid #000000;
- border-left: 2px solid #000000;
- border-right: 2px solid #000000;
- border-top-left-radius: 4px;
- border-top-right-radius: 4px;
- padding: 3px 3px 10px;
- width: 384px;
- background-color: #42b149;
-}
-
-#inventory-table td{
- width: 40px;
- height: 40px;
- text-align: center;
- vertical-align: middle;
-}
-
-#inventory-table img{
- height: 100%;
- max-width: 40px;
- max-height: 40px;
- filter: grayscale(100%) contrast(75%) brightness(30%);
-}
-
-#inventory-table img.acquired{
- filter: none;
-}
-
-#inventory-table div.counted-item {
- position: relative;
-}
-
-#inventory-table div.item-count {
- position: absolute;
- color: white;
- font-family: "Minecraftia", monospace;
- font-weight: bold;
- bottom: 0;
- right: 0;
-}
-
-#location-table{
- width: 384px;
- border-left: 2px solid #000000;
- border-right: 2px solid #000000;
- border-bottom: 2px solid #000000;
- border-bottom-left-radius: 4px;
- border-bottom-right-radius: 4px;
- background-color: #42b149;
- padding: 0 3px 3px;
- font-family: "Minecraftia", monospace;
- font-size: 14px;
- cursor: default;
-}
-
-#location-table th{
- vertical-align: middle;
- text-align: left;
- padding-right: 10px;
-}
-
-#location-table td{
- padding-top: 2px;
- padding-bottom: 2px;
- line-height: 20px;
-}
-
-#location-table td.counter {
- text-align: right;
- font-size: 14px;
-}
-
-#location-table td.toggle-arrow {
- text-align: right;
-}
-
-#location-table tr#Total-header {
- font-weight: bold;
-}
-
-#location-table img{
- height: 100%;
- max-width: 30px;
- max-height: 30px;
-}
-
-#location-table tbody.locations {
- font-size: 12px;
-}
-
-#location-table td.location-name {
- padding-left: 16px;
-}
-
-.hide {
- display: none;
-}
diff --git a/WebHostLib/static/styles/player-settings.css b/WebHostLib/static/styles/player-settings.css
deleted file mode 100644
index 9ba47d5fd02d..000000000000
--- a/WebHostLib/static/styles/player-settings.css
+++ /dev/null
@@ -1,194 +0,0 @@
-html{
- background-image: url('../static/backgrounds/grass.png');
- background-repeat: repeat;
- background-size: 650px 650px;
-}
-
-#player-settings{
- max-width: 1000px;
- margin-left: auto;
- margin-right: auto;
- background-color: rgba(0, 0, 0, 0.15);
- border-radius: 8px;
- padding: 1rem;
- color: #eeffeb;
-}
-
-#player-settings #player-settings-button-row{
- display: flex;
- flex-direction: row;
- justify-content: space-between;
- margin-top: 15px;
-}
-
-#player-settings code{
- background-color: #d9cd8e;
- border-radius: 4px;
- padding-left: 0.25rem;
- padding-right: 0.25rem;
- color: #000000;
-}
-
-#player-settings #user-message{
- display: none;
- width: calc(100% - 8px);
- background-color: #ffe86b;
- border-radius: 4px;
- color: #000000;
- padding: 4px;
- text-align: center;
-}
-
-#player-settings #user-message.visible{
- display: block;
- cursor: pointer;
-}
-
-#player-settings h1{
- font-size: 2.5rem;
- font-weight: normal;
- width: 100%;
- margin-bottom: 0.5rem;
- text-shadow: 1px 1px 4px #000000;
-}
-
-#player-settings h2{
- font-size: 40px;
- font-weight: normal;
- width: 100%;
- margin-bottom: 0.5rem;
- text-transform: lowercase;
- text-shadow: 1px 1px 2px #000000;
-}
-
-#player-settings h3, #player-settings h4, #player-settings h5, #player-settings h6{
- text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
-}
-
-#player-settings input:not([type]){
- border: 1px solid #000000;
- padding: 3px;
- border-radius: 3px;
- min-width: 150px;
-}
-
-#player-settings input:not([type]):focus{
- border: 1px solid #ffffff;
-}
-
-#player-settings select{
- border: 1px solid #000000;
- padding: 3px;
- border-radius: 3px;
- min-width: 150px;
- background-color: #ffffff;
-}
-
-#player-settings #game-options, #player-settings #rom-options{
- display: flex;
- flex-direction: row;
-}
-
-#player-settings .left, #player-settings .right{
- flex-grow: 1;
-}
-
-#player-settings .left{
- margin-right: 10px;
-}
-
-#player-settings .right{
- margin-left: 10px;
-}
-
-#player-settings table{
- margin-bottom: 30px;
- width: 100%;
-}
-
-#player-settings table .select-container{
- display: flex;
- flex-direction: row;
-}
-
-#player-settings table .select-container select{
- min-width: 200px;
- flex-grow: 1;
-}
-
-#player-settings table select:disabled{
- background-color: lightgray;
-}
-
-#player-settings table .range-container{
- display: flex;
- flex-direction: row;
-}
-
-#player-settings table .range-container input[type=range]{
- flex-grow: 1;
-}
-
-#player-settings table .range-value{
- min-width: 20px;
- margin-left: 0.25rem;
-}
-
-#player-settings table .special-range-container{
- display: flex;
- flex-direction: column;
-}
-
-#player-settings table .special-range-wrapper{
- display: flex;
- flex-direction: row;
- margin-top: 0.25rem;
-}
-
-#player-settings table .special-range-wrapper input[type=range]{
- flex-grow: 1;
-}
-
-#player-settings table .randomize-button {
- max-height: 24px;
- line-height: 16px;
- padding: 2px 8px;
- margin: 0 0 0 0.25rem;
- font-size: 12px;
- border: 1px solid black;
- border-radius: 3px;
-}
-
-#player-settings table .randomize-button.active {
- background-color: #ffef00; /* Same as .interactive in globalStyles.css */
-}
-
-#player-settings table label{
- display: block;
- min-width: 200px;
- margin-right: 4px;
- cursor: default;
-}
-
-#player-settings th, #player-settings td{
- border: none;
- padding: 3px;
- font-size: 17px;
- vertical-align: top;
-}
-
-@media all and (max-width: 1000px), all and (orientation: portrait){
- #player-settings #game-options{
- justify-content: flex-start;
- flex-wrap: wrap;
- }
-
- #player-settings .left, #player-settings .right{
- flex-grow: unset;
- }
-
- #game-options table label{
- display: block;
- min-width: 200px;
- }
-}
diff --git a/WebHostLib/static/styles/playerOptions/playerOptions.css b/WebHostLib/static/styles/playerOptions/playerOptions.css
new file mode 100644
index 000000000000..56c9263d3330
--- /dev/null
+++ b/WebHostLib/static/styles/playerOptions/playerOptions.css
@@ -0,0 +1,310 @@
+@import "../markdown.css";
+html {
+ background-image: url("../../static/backgrounds/grass.png");
+ background-repeat: repeat;
+ background-size: 650px 650px;
+ overflow-x: hidden;
+}
+
+#player-options {
+ box-sizing: border-box;
+ max-width: 1024px;
+ margin-left: auto;
+ margin-right: auto;
+ background-color: rgba(0, 0, 0, 0.15);
+ border-radius: 8px;
+ padding: 1rem;
+ color: #eeffeb;
+ word-break: break-word;
+}
+#player-options #player-options-header h1 {
+ margin-bottom: 0;
+ padding-bottom: 0;
+}
+#player-options #player-options-header h1:nth-child(2) {
+ font-size: 1.4rem;
+ margin-top: -8px;
+ margin-bottom: 0.5rem;
+}
+#player-options .js-warning-banner {
+ width: calc(100% - 1rem);
+ padding: 0.5rem;
+ border-radius: 4px;
+ background-color: #f3f309;
+ color: #000000;
+ margin-bottom: 0.5rem;
+ text-align: center;
+}
+#player-options .group-container {
+ padding: 0;
+ margin: 0;
+}
+#player-options .group-container h2 {
+ user-select: none;
+ cursor: unset;
+}
+#player-options .group-container h2 label {
+ cursor: pointer;
+}
+#player-options #player-options-button-row {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ margin-top: 15px;
+}
+#player-options #user-message {
+ display: none;
+ width: calc(100% - 8px);
+ background-color: #ffe86b;
+ border-radius: 4px;
+ color: #000000;
+ padding: 4px;
+ text-align: center;
+ cursor: pointer;
+}
+#player-options h1 {
+ font-size: 2.5rem;
+ font-weight: normal;
+ width: 100%;
+ margin-bottom: 0.5rem;
+ text-shadow: 1px 1px 4px #000000;
+}
+#player-options h2 {
+ font-size: 40px;
+ font-weight: normal;
+ width: 100%;
+ margin-bottom: 0.5rem;
+ text-transform: lowercase;
+ text-shadow: 1px 1px 2px #000000;
+}
+#player-options h3, #player-options h4, #player-options h5, #player-options h6 {
+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
+}
+#player-options input:not([type]) {
+ border: 1px solid #000000;
+ padding: 3px;
+ border-radius: 3px;
+ min-width: 150px;
+}
+#player-options input:not([type]):focus {
+ border: 1px solid #ffffff;
+}
+#player-options select {
+ border: 1px solid #000000;
+ padding: 3px;
+ border-radius: 3px;
+ min-width: 150px;
+ background-color: #ffffff;
+ text-overflow: ellipsis;
+}
+#player-options .game-options {
+ display: flex;
+ flex-direction: row;
+}
+#player-options .game-options .left, #player-options .game-options .right {
+ display: grid;
+ grid-template-columns: 12rem auto;
+ grid-row-gap: 0.5rem;
+ grid-auto-rows: min-content;
+ align-items: start;
+ min-width: 480px;
+ width: 50%;
+}
+#player-options #meta-options {
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 3px;
+}
+#player-options #meta-options input, #player-options #meta-options select {
+ box-sizing: border-box;
+ width: 200px;
+}
+#player-options .left, #player-options .right {
+ flex-grow: 1;
+ margin-bottom: 0.5rem;
+}
+#player-options .left {
+ margin-right: 20px;
+}
+#player-options .select-container {
+ display: flex;
+ flex-direction: row;
+ max-width: 270px;
+}
+#player-options .select-container select {
+ min-width: 200px;
+ flex-grow: 1;
+}
+#player-options .select-container select:disabled {
+ background-color: lightgray;
+}
+#player-options .range-container {
+ display: flex;
+ flex-direction: row;
+ max-width: 270px;
+}
+#player-options .range-container input[type=range] {
+ flex-grow: 1;
+}
+#player-options .range-container .range-value {
+ min-width: 20px;
+ margin-left: 0.25rem;
+}
+#player-options .named-range-container {
+ display: flex;
+ flex-direction: column;
+ max-width: 270px;
+}
+#player-options .named-range-container .named-range-wrapper {
+ display: flex;
+ flex-direction: row;
+ margin-top: 0.25rem;
+}
+#player-options .named-range-container .named-range-wrapper input[type=range] {
+ flex-grow: 1;
+}
+#player-options .free-text-container {
+ display: flex;
+ flex-direction: column;
+ max-width: 270px;
+}
+#player-options .free-text-container input[type=text] {
+ flex-grow: 1;
+}
+#player-options .text-choice-container {
+ display: flex;
+ flex-direction: column;
+ max-width: 270px;
+}
+#player-options .text-choice-container .text-choice-wrapper {
+ display: flex;
+ flex-direction: row;
+ margin-bottom: 0.25rem;
+}
+#player-options .text-choice-container .text-choice-wrapper select {
+ flex-grow: 1;
+}
+#player-options .option-container {
+ display: flex;
+ flex-direction: column;
+ background-color: rgba(0, 0, 0, 0.25);
+ border: 1px solid rgba(20, 20, 20, 0.25);
+ border-radius: 3px;
+ color: #ffffff;
+ max-height: 10rem;
+ min-width: 14.5rem;
+ overflow-y: auto;
+ padding-right: 0.25rem;
+ padding-left: 0.25rem;
+}
+#player-options .option-container .option-divider {
+ width: 100%;
+ height: 2px;
+ background-color: rgba(20, 20, 20, 0.25);
+ margin-top: 0.125rem;
+ margin-bottom: 0.125rem;
+}
+#player-options .option-container .option-entry {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ margin-bottom: 0.125rem;
+ margin-top: 0.125rem;
+ user-select: none;
+}
+#player-options .option-container .option-entry:hover {
+ background-color: rgba(20, 20, 20, 0.25);
+}
+#player-options .option-container .option-entry input[type=checkbox] {
+ margin-right: 0.25rem;
+}
+#player-options .option-container .option-entry input[type=number] {
+ max-width: 1.5rem;
+ max-height: 1rem;
+ margin-left: 0.125rem;
+ text-align: center;
+ /* Hide arrows on input[type=number] fields */
+ -moz-appearance: textfield;
+}
+#player-options .option-container .option-entry input[type=number]::-webkit-outer-spin-button, #player-options .option-container .option-entry input[type=number]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+#player-options .option-container .option-entry label {
+ flex-grow: 1;
+ margin-right: 0;
+ min-width: unset;
+ display: unset;
+}
+#player-options .randomize-button {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ height: 22px;
+ max-width: 30px;
+ margin: 0 0 0 0.25rem;
+ font-size: 14px;
+ border: 1px solid black;
+ border-radius: 3px;
+ background-color: #d3d3d3;
+ user-select: none;
+}
+#player-options .randomize-button:hover {
+ background-color: #c0c0c0;
+ cursor: pointer;
+}
+#player-options .randomize-button label {
+ line-height: 22px;
+ padding-left: 5px;
+ padding-right: 2px;
+ margin-right: 4px;
+ width: 100%;
+ height: 100%;
+ min-width: unset;
+}
+#player-options .randomize-button label:hover {
+ cursor: pointer;
+}
+#player-options .randomize-button input[type=checkbox] {
+ display: none;
+}
+#player-options .randomize-button:has(input[type=checkbox]:checked) {
+ background-color: #ffef00; /* Same as .interactive in globalStyles.css */
+}
+#player-options .randomize-button:has(input[type=checkbox]:checked):hover {
+ background-color: #eedd27;
+}
+#player-options .randomize-button[data-tooltip]::after {
+ left: unset;
+ right: 0;
+}
+#player-options label {
+ display: block;
+ margin-right: 4px;
+ cursor: default;
+ word-break: break-word;
+}
+#player-options th, #player-options td {
+ border: none;
+ padding: 3px;
+ font-size: 17px;
+ vertical-align: top;
+}
+
+@media all and (max-width: 1024px) {
+ #player-options {
+ border-radius: 0;
+ }
+ #player-options #meta-options {
+ flex-direction: column;
+ justify-content: flex-start;
+ gap: 6px;
+ }
+ #player-options .game-options {
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ }
+}
+
+/*# sourceMappingURL=playerOptions.css.map */
diff --git a/WebHostLib/static/styles/playerOptions/playerOptions.css.map b/WebHostLib/static/styles/playerOptions/playerOptions.css.map
new file mode 100644
index 000000000000..6797b88c7bfe
--- /dev/null
+++ b/WebHostLib/static/styles/playerOptions/playerOptions.css.map
@@ -0,0 +1 @@
+{"version":3,"sourceRoot":"","sources":["playerOptions.scss"],"names":[],"mappings":"AAAQ;AAER;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGI;EACI;EACA;;AAGJ;EACI;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;;AAEA;EACI;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;AAEA;EACA;;AACA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;;AAIR;EACI;;AAGJ;EACI;;AAEA;EACI;;AAIR;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;;AAIR;EACI;IACI;;EAEA;IACI;IACA;IACA;;EAGJ;IACI;IACA","file":"playerOptions.css"}
\ No newline at end of file
diff --git a/WebHostLib/static/styles/playerOptions/playerOptions.scss b/WebHostLib/static/styles/playerOptions/playerOptions.scss
new file mode 100644
index 000000000000..06bde759d263
--- /dev/null
+++ b/WebHostLib/static/styles/playerOptions/playerOptions.scss
@@ -0,0 +1,364 @@
+@import "../markdown.css";
+
+html{
+ background-image: url('../../static/backgrounds/grass.png');
+ background-repeat: repeat;
+ background-size: 650px 650px;
+ overflow-x: hidden;
+}
+
+#player-options{
+ box-sizing: border-box;
+ max-width: 1024px;
+ margin-left: auto;
+ margin-right: auto;
+ background-color: rgba(0, 0, 0, 0.15);
+ border-radius: 8px;
+ padding: 1rem;
+ color: #eeffeb;
+ word-break: break-word;
+
+ #player-options-header{
+ h1{
+ margin-bottom: 0;
+ padding-bottom: 0;
+ }
+
+ h1:nth-child(2){
+ font-size: 1.4rem;
+ margin-top: -8px;
+ margin-bottom: 0.5rem;
+ }
+ }
+
+ .js-warning-banner{
+ width: calc(100% - 1rem);
+ padding: 0.5rem;
+ border-radius: 4px;
+ background-color: #f3f309;
+ color: #000000;
+ margin-bottom: 0.5rem;
+ text-align: center;
+ }
+
+ .group-container{
+ padding: 0;
+ margin: 0;
+
+ h2{
+ user-select: none;
+ cursor: unset;
+
+ label{
+ cursor: pointer;
+ }
+ }
+ }
+
+ #player-options-button-row{
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ margin-top: 15px;
+ }
+
+ #user-message{
+ display: none;
+ width: calc(100% - 8px);
+ background-color: #ffe86b;
+ border-radius: 4px;
+ color: #000000;
+ padding: 4px;
+ text-align: center;
+ cursor: pointer;
+ }
+
+ h1{
+ font-size: 2.5rem;
+ font-weight: normal;
+ width: 100%;
+ margin-bottom: 0.5rem;
+ text-shadow: 1px 1px 4px #000000;
+ }
+
+ h2{
+ font-size: 40px;
+ font-weight: normal;
+ width: 100%;
+ margin-bottom: 0.5rem;
+ text-transform: lowercase;
+ text-shadow: 1px 1px 2px #000000;
+ }
+
+ h3, h4, h5, h6{
+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
+ }
+
+ input:not([type]){
+ border: 1px solid #000000;
+ padding: 3px;
+ border-radius: 3px;
+ min-width: 150px;
+
+ &:focus{
+ border: 1px solid #ffffff;
+ }
+ }
+
+ select{
+ border: 1px solid #000000;
+ padding: 3px;
+ border-radius: 3px;
+ min-width: 150px;
+ background-color: #ffffff;
+ text-overflow: ellipsis;
+ }
+
+ .game-options{
+ display: flex;
+ flex-direction: row;
+
+ .left, .right{
+ display: grid;
+ grid-template-columns: 12rem auto;
+ grid-row-gap: 0.5rem;
+ grid-auto-rows: min-content;
+ align-items: start;
+ min-width: 480px;
+ width: 50%;
+ }
+ }
+
+ #meta-options{
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 3px;
+
+ input, select{
+ box-sizing: border-box;
+ width: 200px;
+ }
+ }
+
+ .left, .right{
+ flex-grow: 1;
+ margin-bottom: 0.5rem;
+ }
+
+ .left{
+ margin-right: 20px;
+ }
+
+ .select-container{
+ display: flex;
+ flex-direction: row;
+ max-width: 270px;
+
+ select{
+ min-width: 200px;
+ flex-grow: 1;
+
+ &:disabled{
+ background-color: lightgray;
+ }
+ }
+ }
+
+ .range-container{
+ display: flex;
+ flex-direction: row;
+ max-width: 270px;
+
+ input[type=range]{
+ flex-grow: 1;
+ }
+
+ .range-value{
+ min-width: 20px;
+ margin-left: 0.25rem;
+ }
+ }
+
+ .named-range-container{
+ display: flex;
+ flex-direction: column;
+ max-width: 270px;
+
+ .named-range-wrapper{
+ display: flex;
+ flex-direction: row;
+ margin-top: 0.25rem;
+
+ input[type=range]{
+ flex-grow: 1;
+ }
+ }
+ }
+
+ .free-text-container{
+ display: flex;
+ flex-direction: column;
+ max-width: 270px;
+
+ input[type=text]{
+ flex-grow: 1;
+ }
+ }
+
+ .text-choice-container{
+ display: flex;
+ flex-direction: column;
+ max-width: 270px;
+
+ .text-choice-wrapper{
+ display: flex;
+ flex-direction: row;
+ margin-bottom: 0.25rem;
+
+ select{
+ flex-grow: 1;
+ }
+ }
+ }
+
+ .option-container{
+ display: flex;
+ flex-direction: column;
+ background-color: rgba(0, 0, 0, 0.25);
+ border: 1px solid rgba(20, 20, 20, 0.25);
+ border-radius: 3px;
+ color: #ffffff;
+ max-height: 10rem;
+ min-width: 14.5rem;
+ overflow-y: auto;
+ padding-right: 0.25rem;
+ padding-left: 0.25rem;
+
+ .option-divider{
+ width: 100%;
+ height: 2px;
+ background-color: rgba(20, 20, 20, 0.25);
+ margin-top: 0.125rem;
+ margin-bottom: 0.125rem;
+ }
+
+ .option-entry{
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ margin-bottom: 0.125rem;
+ margin-top: 0.125rem;
+ user-select: none;
+
+ &:hover{
+ background-color: rgba(20, 20, 20, 0.25);
+ }
+
+ input[type=checkbox]{
+ margin-right: 0.25rem;
+ }
+
+ input[type=number]{
+ max-width: 1.5rem;
+ max-height: 1rem;
+ margin-left: 0.125rem;
+ text-align: center;
+
+ /* Hide arrows on input[type=number] fields */
+ -moz-appearance: textfield;
+ &::-webkit-outer-spin-button, &::-webkit-inner-spin-button{
+ -webkit-appearance: none;
+ margin: 0;
+ }
+ }
+
+ label{
+ flex-grow: 1;
+ margin-right: 0;
+ min-width: unset;
+ display: unset;
+ }
+ }
+ }
+
+ .randomize-button{
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ height: 22px;
+ max-width: 30px;
+ margin: 0 0 0 0.25rem;
+ font-size: 14px;
+ border: 1px solid black;
+ border-radius: 3px;
+ background-color: #d3d3d3;
+ user-select: none;
+
+ &:hover{
+ background-color: #c0c0c0;
+ cursor: pointer;
+ }
+
+ label{
+ line-height: 22px;
+ padding-left: 5px;
+ padding-right: 2px;
+ margin-right: 4px;
+ width: 100%;
+ height: 100%;
+ min-width: unset;
+ &:hover{
+ cursor: pointer;
+ }
+ }
+
+ input[type=checkbox]{
+ display: none;
+ }
+
+ &:has(input[type=checkbox]:checked){
+ background-color: #ffef00; /* Same as .interactive in globalStyles.css */
+
+ &:hover{
+ background-color: #eedd27;
+ }
+ }
+
+ &[data-tooltip]::after{
+ left: unset;
+ right: 0;
+ }
+ }
+
+ label{
+ display: block;
+ margin-right: 4px;
+ cursor: default;
+ word-break: break-word;
+ }
+
+ th, td{
+ border: none;
+ padding: 3px;
+ font-size: 17px;
+ vertical-align: top;
+ }
+}
+
+@media all and (max-width: 1024px) {
+ #player-options {
+ border-radius: 0;
+
+ #meta-options {
+ flex-direction: column;
+ justify-content: flex-start;
+ gap: 6px;
+ }
+
+ .game-options{
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ }
+ }
+}
diff --git a/WebHostLib/static/styles/sc2Tracker.css b/WebHostLib/static/styles/sc2Tracker.css
new file mode 100644
index 000000000000..28a4e7bc8a22
--- /dev/null
+++ b/WebHostLib/static/styles/sc2Tracker.css
@@ -0,0 +1,276 @@
+*{
+ margin: 0;
+ font-family: "JuraBook", monospace;
+}
+body{
+ --icon-size: 36px;
+ --item-class-padding: 4px;
+}
+a{
+ color: #1ae;
+}
+
+/* Section colours */
+#player-info{
+ background-color: #37a;
+}
+.player-tracker{
+ max-width: 100%;
+}
+.tracker-section{
+ background-color: grey;
+}
+#terran-items{
+ background-color: #3a7;
+}
+#zerg-items{
+ background-color: #d94;
+}
+#protoss-items{
+ background-color: #37a;
+}
+#nova-items{
+ background-color: #777;
+}
+#kerrigan-items{
+ background-color: #a37;
+}
+#keys{
+ background-color: #aa2;
+}
+
+/* Sections */
+.section-body{
+ display: flex;
+ flex-flow: row wrap;
+ justify-content: flex-start;
+ align-items: flex-start;
+ padding-bottom: 3px;
+}
+.section-body-2{
+ display: flex;
+ flex-direction: column;
+}
+.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body,
+.tracker-section:has(input.collapse-section[type=checkbox]:checked) .section-body-2{
+ display: none;
+}
+.section-title{
+ position: relative;
+ border-bottom: 3px solid black;
+ /* Prevent text selection */
+ user-select: none;
+ -webkit-user-select: none;
+ -ms-user-select: none;
+}
+input[type="checkbox"]{
+ position: absolute;
+ cursor: pointer;
+ opacity: 0;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+}
+.section-title:hover h2{
+ text-shadow: 0 0 4px #ddd;
+}
+.f {
+ display: flex;
+ overflow: hidden;
+}
+
+/* Acquire item filters */
+.tracker-section img{
+ height: 100%;
+ width: var(--icon-size);
+ height: var(--icon-size);
+ background-color: black;
+}
+.unacquired, .lvl-0 .f{
+ filter: grayscale(100%) contrast(80%) brightness(42%) blur(0.5px);
+}
+.spacer{
+ width: var(--icon-size);
+ height: var(--icon-size);
+}
+
+/* Item groups */
+.item-class{
+ display: flex;
+ flex-flow: column;
+ justify-content: center;
+ padding: var(--item-class-padding);
+}
+.item-class-header{
+ display: flex;
+ flex-flow: row;
+}
+.item-class-upgrades{
+ /* Note: {display: flex; flex-flow: column wrap} */
+ /* just breaks on Firefox (width does not scale to content) */
+ display: grid;
+ grid-template-rows: repeat(4, auto);
+ grid-auto-flow: column;
+}
+
+/* Subsections */
+.section-toc{
+ display: flex;
+ flex-direction: row;
+}
+.toc-box{
+ position: relative;
+ padding-left: 15px;
+ padding-right: 15px;
+}
+.toc-box:hover{
+ text-shadow: 0 0 7px white;
+}
+.ss-header{
+ position: relative;
+ text-align: center;
+ writing-mode: sideways-lr;
+ user-select: none;
+ padding-top: 5px;
+ font-size: 115%;
+}
+.tracker-section:has(input.ss-1-toggle:checked) .ss-1{
+ display: none;
+}
+.tracker-section:has(input.ss-2-toggle:checked) .ss-2{
+ display: none;
+}
+.tracker-section:has(input.ss-3-toggle:checked) .ss-3{
+ display: none;
+}
+.tracker-section:has(input.ss-4-toggle:checked) .ss-4{
+ display: none;
+}
+.tracker-section:has(input.ss-5-toggle:checked) .ss-5{
+ display: none;
+}
+.tracker-section:has(input.ss-6-toggle:checked) .ss-6{
+ display: none;
+}
+.tracker-section:has(input.ss-7-toggle:checked) .ss-7{
+ display: none;
+}
+.tracker-section:has(input.ss-1-toggle:hover) .ss-1{
+ background-color: #fff5;
+ box-shadow: 0 0 1px 1px white;
+}
+.tracker-section:has(input.ss-2-toggle:hover) .ss-2{
+ background-color: #fff5;
+ box-shadow: 0 0 1px 1px white;
+}
+.tracker-section:has(input.ss-3-toggle:hover) .ss-3{
+ background-color: #fff5;
+ box-shadow: 0 0 1px 1px white;
+}
+.tracker-section:has(input.ss-4-toggle:hover) .ss-4{
+ background-color: #fff5;
+ box-shadow: 0 0 1px 1px white;
+}
+.tracker-section:has(input.ss-5-toggle:hover) .ss-5{
+ background-color: #fff5;
+ box-shadow: 0 0 1px 1px white;
+}
+.tracker-section:has(input.ss-6-toggle:hover) .ss-6{
+ background-color: #fff5;
+ box-shadow: 0 0 1px 1px white;
+}
+.tracker-section:has(input.ss-7-toggle:hover) .ss-7{
+ background-color: #fff5;
+ box-shadow: 0 0 1px 1px white;
+}
+
+/* Progressive items */
+.progressive{
+ max-height: var(--icon-size);
+ display: contents;
+}
+
+.lvl-0 > :nth-child(2),
+.lvl-0 > :nth-child(3),
+.lvl-0 > :nth-child(4),
+.lvl-0 > :nth-child(5){
+ display: none;
+}
+.lvl-1 > :nth-child(2),
+.lvl-1 > :nth-child(3),
+.lvl-1 > :nth-child(4),
+.lvl-1 > :nth-child(5){
+ display: none;
+}
+.lvl-2 > :nth-child(1),
+.lvl-2 > :nth-child(3),
+.lvl-2 > :nth-child(4),
+.lvl-2 > :nth-child(5){
+ display: none;
+}
+.lvl-3 > :nth-child(1),
+.lvl-3 > :nth-child(2),
+.lvl-3 > :nth-child(4),
+.lvl-3 > :nth-child(5){
+ display: none;
+}
+.lvl-4 > :nth-child(1),
+.lvl-4 > :nth-child(2),
+.lvl-4 > :nth-child(3),
+.lvl-4 > :nth-child(5){
+ display: none;
+}
+.lvl-5 > :nth-child(1),
+.lvl-5 > :nth-child(2),
+.lvl-5 > :nth-child(3),
+.lvl-5 > :nth-child(4){
+ display: none;
+}
+
+/* Filler item counters */
+.item-counter{
+ display: table;
+ text-align: center;
+ padding: var(--item-class-padding);
+}
+.item-count{
+ display: table-cell;
+ vertical-align: middle;
+ padding-left: 3px;
+ padding-right: 15px;
+}
+
+/* Hidden items */
+.hidden-class:not(:has(.f:not(.unacquired))), .hidden-item{
+ display: none;
+}
+
+/* Keys */
+#keys ol, #keys ul{
+ columns: 3;
+ -webkit-columns: 3;
+ -moz-columns: 3;
+}
+#keys li{
+ padding-right: 15pt;
+}
+
+/* Locations */
+#section-locations{
+ padding-left: 5px;
+}
+@media only screen and (min-width: 120ch){
+ #section-locations ul{
+ columns: 2;
+ -webkit-columns: 2;
+ -moz-columns: 2;
+ }
+}
+#locations li.checked{
+ list-style-type: "â ";
+}
+
+/* Allowing scrolling down a little further */
+.bottom-padding{
+ min-height: 33vh;
+}
\ No newline at end of file
diff --git a/WebHostLib/static/styles/sc2TrackerAtlas.css b/WebHostLib/static/styles/sc2TrackerAtlas.css
new file mode 100644
index 000000000000..7fc8746f6f90
--- /dev/null
+++ b/WebHostLib/static/styles/sc2TrackerAtlas.css
@@ -0,0 +1,3965 @@
+.abilityicon_spawnbanelings_square-png{
+ clip-path: xywh(0 0.0% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 49.93694829760403%);
+}
+
+.abilityicon_spawnbroodlings_square-png{
+ clip-path: xywh(0 0.12610340479192939% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 49.810844892812106%);
+}
+
+.biomassrecovery_coop-png{
+ clip-path: xywh(0 0.25220680958385877% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 49.68474148802018%);
+}
+
+.btn-ability-dehaka-airbonusdamage-png{
+ clip-path: xywh(0 0.37831021437578816% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 49.558638083228246%);
+}
+
+.btn-ability-hornerhan-fleethyperjump-png{
+ clip-path: xywh(0 0.5044136191677175% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 49.43253467843632%);
+}
+
+.btn-ability-hornerhan-raven-analyzetarget-png{
+ clip-path: xywh(0 0.6305170239596469% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 49.306431273644385%);
+}
+
+.btn-ability-hornerhan-reaper-flightmode-png{
+ clip-path: xywh(0 0.7566204287515763% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 49.18032786885246%);
+}
+
+.btn-ability-hornerhan-salvagebonus-png{
+ clip-path: xywh(0 0.8827238335435057% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 49.05422446406053%);
+}
+
+.btn-ability-hornerhan-viking-missileupgrade-png{
+ clip-path: xywh(0 1.008827238335435% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 48.9281210592686%);
+}
+
+.btn-ability-hornerhan-viking-piercingattacks-png{
+ clip-path: xywh(0 1.1349306431273645% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 48.80201765447667%);
+}
+
+.btn-ability-hornerhan-widowmine-attackrange-png{
+ clip-path: xywh(0 1.2610340479192939% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 48.675914249684745%);
+}
+
+.btn-ability-hornerhan-widowmine-deathblossom-png{
+ clip-path: xywh(0 1.3871374527112232% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 48.54981084489281%);
+}
+
+.btn-ability-hornerhan-wraith-attackspeed-png{
+ clip-path: xywh(0 1.5132408575031526% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 48.423707440100884%);
+}
+
+.btn-ability-kerrigan-abilityefficiency-png{
+ clip-path: xywh(0 1.639344262295082% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 48.29760403530895%);
+}
+
+.btn-ability-kerrigan-apocalypse-png{
+ clip-path: xywh(0 1.7654476670870114% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 48.17150063051702%);
+}
+
+.btn-ability-kerrigan-automatedextractors-png{
+ clip-path: xywh(0 1.8915510718789408% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 48.0453972257251%);
+}
+
+.btn-ability-kerrigan-broodlingnest-png{
+ clip-path: xywh(0 2.01765447667087% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 47.91929382093316%);
+}
+
+.btn-ability-kerrigan-droppods-png{
+ clip-path: xywh(0 2.1437578814627996% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 47.793190416141236%);
+}
+
+.btn-ability-kerrigan-fury-png{
+ clip-path: xywh(0 2.269861286254729% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 47.66708701134931%);
+}
+
+.btn-ability-kerrigan-heroicfortitude-png{
+ clip-path: xywh(0 2.3959646910466583% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 47.540983606557376%);
+}
+
+.btn-ability-kerrigan-improvedoverlords-png{
+ clip-path: xywh(0 2.5220680958385877% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 47.41488020176545%);
+}
+
+.btn-ability-kerrigan-kineticblast-png{
+ clip-path: xywh(0 2.648171500630517% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 47.288776796973515%);
+}
+
+.btn-ability-kerrigan-leapingstrike-png{
+ clip-path: xywh(0 2.7742749054224465% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 47.16267339218159%);
+}
+
+.btn-ability-kerrigan-malignantcreep-png{
+ clip-path: xywh(0 2.900378310214376% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 47.03656998738966%);
+}
+
+.btn-ability-kerrigan-psychicshift-png{
+ clip-path: xywh(0 3.0264817150063053% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 46.91046658259773%);
+}
+
+.btn-ability-kerrigan-revive-png{
+ clip-path: xywh(0 3.1525851197982346% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 46.7843631778058%);
+}
+
+.btn-ability-kerrigan-twindrones-png{
+ clip-path: xywh(0 3.278688524590164% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 46.658259773013874%);
+}
+
+.btn-ability-kerrigan-vespeneefficiency-png{
+ clip-path: xywh(0 3.4047919293820934% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 46.53215636822194%);
+}
+
+.btn-ability-kerrigan-wildmutation-png{
+ clip-path: xywh(0 3.530895334174023% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 46.406052963430014%);
+}
+
+.btn-ability-kerrigan-zerglingreconstitution-png{
+ clip-path: xywh(0 3.656998738965952% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 46.27994955863808%);
+}
+
+.btn-ability-mengsk-battlecruiser-decksights-png{
+ clip-path: xywh(0 3.7831021437578816% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 46.15384615384615%);
+}
+
+.btn-ability-mengsk-ghost-pyrokineticimmolation_orange-png{
+ clip-path: xywh(0 3.909205548549811% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 46.02774274905423%);
+}
+
+.btn-ability-mengsk-ghost-staticempblast-png{
+ clip-path: xywh(0 4.03530895334174% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 45.90163934426229%);
+}
+
+.btn-ability-mengsk-ghost-tacticalmissilestrike-png{
+ clip-path: xywh(0 4.16141235813367% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 45.775535939470366%);
+}
+
+.btn-ability-mengsk-medivac-doublehealbeam-png{
+ clip-path: xywh(0 4.287515762925599% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 45.64943253467844%);
+}
+
+.btn-ability-mengsk-medivac-igniteafterburners-png{
+ clip-path: xywh(0 4.4136191677175285% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 45.523329129886505%);
+}
+
+.btn-ability-mengsk-siegetank-flyingtankarmament-png{
+ clip-path: xywh(0 4.539722572509458% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 45.39722572509458%);
+}
+
+.btn-ability-mengsk-viking-speed-png{
+ clip-path: xywh(0 4.665825977301387% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 45.27112232030265%);
+}
+
+.btn-ability-nova-domination-png{
+ clip-path: xywh(0 4.791929382093317% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 45.14501891551072%);
+}
+
+.btn-ability-protoss-adept-spiritform-png{
+ clip-path: xywh(0 4.918032786885246% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 45.01891551071879%);
+}
+
+.btn-ability-protoss-astralwind-png{
+ clip-path: xywh(0 5.044136191677175% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 44.89281210592686%);
+}
+
+.btn-ability-protoss-barrier-upgraded-png{
+ clip-path: xywh(0 5.170239596469105% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 44.76670870113493%);
+}
+
+.btn-ability-protoss-blink-color-png{
+ clip-path: xywh(0 5.296343001261034% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 44.640605296343004%);
+}
+
+.btn-ability-protoss-blinkshieldrestore-png{
+ clip-path: xywh(0 5.422446406052964% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 44.51450189155107%);
+}
+
+.btn-ability-protoss-carrierrepairdrones-png{
+ clip-path: xywh(0 5.548549810844893% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 44.388398486759144%);
+}
+
+.btn-ability-protoss-chargedblast-png{
+ clip-path: xywh(0 5.674653215636822% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 44.26229508196721%);
+}
+
+.btn-ability-protoss-coronabeam-png{
+ clip-path: xywh(0 5.800756620428752% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 44.13619167717528%);
+}
+
+.btn-ability-protoss-disintegration-png{
+ clip-path: xywh(0 5.926860025220681% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 44.010088272383356%);
+}
+
+.btn-ability-protoss-disruptionblast-png{
+ clip-path: xywh(0 6.0529634300126105% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 43.88398486759142%);
+}
+
+.btn-ability-protoss-doubleshieldrecharge-png{
+ clip-path: xywh(0 6.17906683480454% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 43.757881462799496%);
+}
+
+.btn-ability-protoss-dragoonchassis-png{
+ clip-path: xywh(0 6.305170239596469% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 43.63177805800757%);
+}
+
+.btn-ability-protoss-dualgravitonbeam-png{
+ clip-path: xywh(0 6.431273644388399% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 43.505674653215635%);
+}
+
+.btn-ability-protoss-entomb-png{
+ clip-path: xywh(0 6.557377049180328% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 43.37957124842371%);
+}
+
+.btn-ability-protoss-feedback-color-png{
+ clip-path: xywh(0 6.683480453972257% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 43.25346784363178%);
+}
+
+.btn-ability-protoss-firebeam-png{
+ clip-path: xywh(0 6.809583858764187% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 43.12736443883985%);
+}
+
+.btn-ability-protoss-forcefield-color-png{
+ clip-path: xywh(0 6.935687263556116% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 43.00126103404792%);
+}
+
+.btn-ability-protoss-forceofwill-png{
+ clip-path: xywh(0 7.061790668348046% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 42.87515762925599%);
+}
+
+.btn-ability-protoss-gravitonbeam-color-png{
+ clip-path: xywh(0 7.187894073139975% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 42.74905422446406%);
+}
+
+.btn-ability-protoss-hallucination-color-png{
+ clip-path: xywh(0 7.313997477931904% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 42.622950819672134%);
+}
+
+.btn-ability-protoss-lightningdash-png{
+ clip-path: xywh(0 7.440100882723834% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 42.4968474148802%);
+}
+
+.btn-ability-protoss-massrecall-png{
+ clip-path: xywh(0 7.566204287515763% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 42.37074401008827%);
+}
+
+.btn-ability-protoss-mindblast-png{
+ clip-path: xywh(0 7.6923076923076925% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 42.24464060529634%);
+}
+
+.btn-ability-protoss-oracle-stasiscalibration-png{
+ clip-path: xywh(0 7.818411097099622% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 42.11853720050441%);
+}
+
+.btn-ability-protoss-oraclepulsarcannonon-png{
+ clip-path: xywh(0 7.944514501891551% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 41.992433795712486%);
+}
+
+.btn-ability-protoss-phantomdash-png{
+ clip-path: xywh(0 8.07061790668348% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 41.86633039092055%);
+}
+
+.btn-ability-protoss-prismaticrange-png{
+ clip-path: xywh(0 8.19672131147541% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 41.740226986128626%);
+}
+
+.btn-ability-protoss-purify-png{
+ clip-path: xywh(0 8.32282471626734% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 41.6141235813367%);
+}
+
+.btn-ability-protoss-recallondeath-png{
+ clip-path: xywh(0 8.448928121059268% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 41.488020176544765%);
+}
+
+.btn-ability-protoss-reclamation-png{
+ clip-path: xywh(0 8.575031525851198% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 41.36191677175284%);
+}
+
+.btn-ability-protoss-shadowdash-png{
+ clip-path: xywh(0 8.701134930643127% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 41.23581336696091%);
+}
+
+.btn-ability-protoss-shadowfury-png{
+ clip-path: xywh(0 8.827238335435057% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 41.10970996216898%);
+}
+
+.btn-ability-protoss-shieldrecharge-png{
+ clip-path: xywh(0 8.953341740226985% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 40.98360655737705%);
+}
+
+.btn-ability-protoss-stasistrap-png{
+ clip-path: xywh(0 9.079445145018916% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 40.85750315258512%);
+}
+
+.btn-ability-protoss-supplicant-sacrificeon-png{
+ clip-path: xywh(0 9.205548549810844% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 40.73139974779319%);
+}
+
+.btn-ability-protoss-veilofshadowsvorazun-png{
+ clip-path: xywh(0 9.331651954602775% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 40.60529634300126%);
+}
+
+.btn-ability-protoss-voidstasis-png{
+ clip-path: xywh(0 9.457755359394703% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 40.47919293820933%);
+}
+
+.btn-ability-protoss-vulcanblaster-png{
+ clip-path: xywh(0 9.583858764186633% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 40.3530895334174%);
+}
+
+.btn-ability-protoss-warprelocatelvl2-png{
+ clip-path: xywh(0 9.709962168978562% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 40.22698612862547%);
+}
+
+.btn-ability-protoss-whirlwind-png{
+ clip-path: xywh(0 9.836065573770492% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 40.10088272383354%);
+}
+
+.btn-ability-spearofadun-chronomancy-png{
+ clip-path: xywh(0 9.96216897856242% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 39.974779319041616%);
+}
+
+.btn-ability-spearofadun-chronosurge-png{
+ clip-path: xywh(0 10.08827238335435% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 39.84867591424968%);
+}
+
+.btn-ability-spearofadun-deploypylon-png{
+ clip-path: xywh(0 10.21437578814628% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 39.722572509457756%);
+}
+
+.btn-ability-spearofadun-guardianshell-png{
+ clip-path: xywh(0 10.34047919293821% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 39.59646910466583%);
+}
+
+.btn-ability-spearofadun-massrecall-png{
+ clip-path: xywh(0 10.466582597730138% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 39.470365699873895%);
+}
+
+.btn-ability-spearofadun-matrixoverload-png{
+ clip-path: xywh(0 10.592686002522068% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 39.34426229508197%);
+}
+
+.btn-ability-spearofadun-nexusovercharge-png{
+ clip-path: xywh(0 10.718789407313997% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 39.21815889029004%);
+}
+
+.btn-ability-spearofadun-orbitalassimilator-png{
+ clip-path: xywh(0 10.844892812105927% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 39.09205548549811%);
+}
+
+.btn-ability-spearofadun-orbitalstrike-png{
+ clip-path: xywh(0 10.970996216897856% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 38.96595208070618%);
+}
+
+.btn-ability-spearofadun-purifierbeam-png{
+ clip-path: xywh(0 11.097099621689786% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 38.83984867591425%);
+}
+
+.btn-ability-spearofadun-reconstructionbeam-png{
+ clip-path: xywh(0 11.223203026481714% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 38.71374527112232%);
+}
+
+.btn-ability-spearofadun-shieldovercharge-png{
+ clip-path: xywh(0 11.349306431273645% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 38.58764186633039%);
+}
+
+.btn-ability-spearofadun-solarbombardment-png{
+ clip-path: xywh(0 11.475409836065573% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 38.46153846153846%);
+}
+
+.btn-ability-spearofadun-solarlance-png{
+ clip-path: xywh(0 11.601513240857503% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 38.33543505674653%);
+}
+
+.btn-ability-spearofadun-temporalfield-png{
+ clip-path: xywh(0 11.727616645649432% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 38.2093316519546%);
+}
+
+.btn-ability-spearofadun-timestop-png{
+ clip-path: xywh(0 11.853720050441362% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 38.08322824716267%);
+}
+
+.btn-ability-spearofadun-warpharmonization-png{
+ clip-path: xywh(0 11.97982345523329% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 37.957124842370746%);
+}
+
+.btn-ability-spearofadun-warpinreinforcements-png{
+ clip-path: xywh(0 12.105926860025221% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 37.83102143757881%);
+}
+
+.btn-ability-stetmann-banelingmanashield-png{
+ clip-path: xywh(0 12.23203026481715% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 37.704918032786885%);
+}
+
+.btn-ability-stetmann-corruptormissilebarrage-png{
+ clip-path: xywh(0 12.35813366960908% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 37.57881462799496%);
+}
+
+.btn-ability-stukov-plaugedmunitions-png{
+ clip-path: xywh(0 12.484237074401008% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 37.452711223203025%);
+}
+
+.btn-ability-swarm-kerrigan-chainreaction-png{
+ clip-path: xywh(0 12.610340479192939% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 37.3266078184111%);
+}
+
+.btn-ability-swarm-kerrigan-crushinggrip-png{
+ clip-path: xywh(0 12.736443883984867% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 37.20050441361917%);
+}
+
+.btn-ability-terran-calldownextrasupplies-color-png{
+ clip-path: xywh(0 12.862547288776797% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 37.07440100882724%);
+}
+
+.btn-ability-terran-cloak-color-png{
+ clip-path: xywh(0 12.988650693568726% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 36.94829760403531%);
+}
+
+.btn-ability-terran-detectionconedebuff-png{
+ clip-path: xywh(0 13.114754098360656% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 36.82219419924338%);
+}
+
+.btn-ability-terran-electricfield-png{
+ clip-path: xywh(0 13.240857503152585% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 36.69609079445145%);
+}
+
+.btn-ability-terran-emergencythrusters-png{
+ clip-path: xywh(0 13.366960907944515% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 36.56998738965952%);
+}
+
+.btn-ability-terran-emp-color-png{
+ clip-path: xywh(0 13.493064312736443% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 36.44388398486759%);
+}
+
+.btn-ability-terran-goliath-jetpack-png{
+ clip-path: xywh(0 13.619167717528374% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 36.31778058007566%);
+}
+
+.btn-ability-terran-hercules-tacticaljump-png{
+ clip-path: xywh(0 13.745271122320302% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 36.19167717528373%);
+}
+
+.btn-ability-terran-ignorearmor-png{
+ clip-path: xywh(0 13.871374527112232% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 36.0655737704918%);
+}
+
+.btn-ability-terran-liftoff-png{
+ clip-path: xywh(0 13.997477931904161% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 35.939470365699876%);
+}
+
+.btn-ability-terran-nuclearstrike-color-png{
+ clip-path: xywh(0 14.123581336696091% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 35.81336696090794%);
+}
+
+.btn-ability-terran-psidisruption-png{
+ clip-path: xywh(0 14.24968474148802% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 35.687263556116015%);
+}
+
+.btn-ability-terran-punishergrenade-color-png{
+ clip-path: xywh(0 14.37578814627995% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 35.56116015132409%);
+}
+
+.btn-ability-terran-restorationscbw-png{
+ clip-path: xywh(0 14.501891551071878% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 35.435056746532155%);
+}
+
+.btn-ability-terran-scannersweep-color-png{
+ clip-path: xywh(0 14.627994955863809% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 35.30895334174023%);
+}
+
+.btn-ability-terran-shreddermissile-color-png{
+ clip-path: xywh(0 14.754098360655737% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 35.1828499369483%);
+}
+
+.btn-ability-terran-spidermine-png{
+ clip-path: xywh(0 14.880201765447667% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 35.05674653215637%);
+}
+
+.btn-ability-terran-stimpack-color-png{
+ clip-path: xywh(0 15.006305170239596% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 34.93064312736444%);
+}
+
+.btn-ability-terran-unloadall-png{
+ clip-path: xywh(0 15.132408575031526% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 34.80453972257251%);
+}
+
+.btn-ability-terran-warpjump-png{
+ clip-path: xywh(0 15.258511979823455% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 34.67843631778058%);
+}
+
+.btn-ability-terran-widowminehidden-png{
+ clip-path: xywh(0 15.384615384615385% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 34.552332912988646%);
+}
+
+.btn-ability-thor-330mm-png{
+ clip-path: xywh(0 15.510718789407314% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 34.42622950819672%);
+}
+
+.btn-ability-tychus-herc-heavyimpact-png{
+ clip-path: xywh(0 15.636822194199244% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 34.30012610340479%);
+}
+
+.btn-ability-tychus-medivac-png{
+ clip-path: xywh(0 15.762925598991172% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 34.17402269861286%);
+}
+
+.btn-ability-zeratul-avatarofform-psionicblast-png{
+ clip-path: xywh(0 15.889029003783103% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 34.04791929382093%);
+}
+
+.btn-ability-zeratul-chargedcrystal-psionicwinds-png{
+ clip-path: xywh(0 16.01513240857503% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 33.921815889029006%);
+}
+
+.btn-ability-zeratul-darkarchon-maelstrom-png{
+ clip-path: xywh(0 16.14123581336696% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 33.79571248423707%);
+}
+
+.btn-ability-zeratul-immortal-forcecannon-png{
+ clip-path: xywh(0 16.26733921815889% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 33.669609079445145%);
+}
+
+.btn-ability-zeratul-observer-sensorarray-png{
+ clip-path: xywh(0 16.39344262295082% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 33.54350567465322%);
+}
+
+.btn-ability-zeratul-topbar-serdathlegion-png{
+ clip-path: xywh(0 16.51954602774275% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 33.417402269861284%);
+}
+
+.btn-ability-zerg-abathur-corrosivebilelarge-png{
+ clip-path: xywh(0 16.64564943253468% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 33.29129886506936%);
+}
+
+.btn-ability-zerg-acidspores-png{
+ clip-path: xywh(0 16.77175283732661% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 33.16519546027743%);
+}
+
+.btn-ability-zerg-burrow-color-png{
+ clip-path: xywh(0 16.897856242118536% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 33.0390920554855%);
+}
+
+.btn-ability-zerg-causticspray-png{
+ clip-path: xywh(0 17.023959646910466% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 32.91298865069357%);
+}
+
+.btn-ability-zerg-corruption-color-png{
+ clip-path: xywh(0 17.150063051702396% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 32.786885245901644%);
+}
+
+.btn-ability-zerg-creepspread-png{
+ clip-path: xywh(0 17.276166456494327% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 32.66078184110971%);
+}
+
+.btn-ability-zerg-creepteleport-png{
+ clip-path: xywh(0 17.402269861286253% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 32.534678436317776%);
+}
+
+.btn-ability-zerg-darkswarm-png{
+ clip-path: xywh(0 17.528373266078184% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 32.40857503152586%);
+}
+
+.btn-ability-zerg-deeptunnel-png{
+ clip-path: xywh(0 17.654476670870114% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 32.28247162673392%);
+}
+
+.btn-ability-zerg-dehaka-essencecollector-png{
+ clip-path: xywh(0 17.780580075662044% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 32.15636822194199%);
+}
+
+.btn-ability-zerg-dehaka-guardian-explosivespores-png{
+ clip-path: xywh(0 17.90668348045397% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 32.03026481715006%);
+}
+
+.btn-ability-zerg-dehaka-guardian-primordialfury-png{
+ clip-path: xywh(0 18.0327868852459% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 31.904161412358135%);
+}
+
+.btn-ability-zerg-dehaka-impaler-tenderize-png{
+ clip-path: xywh(0 18.15889029003783% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 31.778058007566205%);
+}
+
+.btn-ability-zerg-dehaka-tyrannozor-barrageofspikes-png{
+ clip-path: xywh(0 18.284993694829762% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 31.651954602774275%);
+}
+
+.btn-ability-zerg-dehaka-tyrannozor-tyrantprotection-png{
+ clip-path: xywh(0 18.41109709962169% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 31.525851197982345%);
+}
+
+.btn-ability-zerg-dehaka-ultralisk-brutalcharge-png{
+ clip-path: xywh(0 18.53720050441362% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 31.399747793190418%);
+}
+
+.btn-ability-zerg-dehaka-ultralisk-healingadaptation-png{
+ clip-path: xywh(0 18.66330390920555% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 31.273644388398488%);
+}
+
+.btn-ability-zerg-dehaka-ultralisk-impalingstrike-png{
+ clip-path: xywh(0 18.78940731399748% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 31.147540983606557%);
+}
+
+.btn-ability-zerg-fireroach-increasefiredamage-png{
+ clip-path: xywh(0 18.915510718789406% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 31.021437578814627%);
+}
+
+.btn-ability-zerg-fungalgrowth-color-png{
+ clip-path: xywh(0 19.041614123581336% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 30.8953341740227%);
+}
+
+.btn-ability-zerg-genemutation-thornsaura-png{
+ clip-path: xywh(0 19.167717528373267% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 30.76923076923077%);
+}
+
+.btn-ability-zerg-generatecreep-color-png{
+ clip-path: xywh(0 19.293820933165197% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 30.64312736443884%);
+}
+
+.btn-ability-zerg-overlord-oversight-off-png{
+ clip-path: xywh(0 19.419924337957124% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 30.51702395964691%);
+}
+
+.btn-ability-zerg-parasiticbomb-png{
+ clip-path: xywh(0 19.546027742749054% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 30.390920554854983%);
+}
+
+.btn-ability-zerg-rapidregeneration-color-png{
+ clip-path: xywh(0 19.672131147540984% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 30.264817150063053%);
+}
+
+.btn-ability-zerg-stukov-ensnare-png{
+ clip-path: xywh(0 19.798234552332914% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 30.138713745271122%);
+}
+
+.btn-ability-zerg-stukov-ensnarecdr-png{
+ clip-path: xywh(0 19.92433795712484% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 30.012610340479192%);
+}
+
+.btn-ability-zerg-transfusion-color-png{
+ clip-path: xywh(0 20.05044136191677% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 29.886506935687265%);
+}
+
+.btn-abilty-terran-lockdownscbw-png{
+ clip-path: xywh(0 20.1765447667087% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 29.760403530895335%);
+}
+
+.btn-accelerated-warp-png{
+ clip-path: xywh(0 20.302648171500632% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 29.634300126103405%);
+}
+
+.btn-adaptive-medpacks-png{
+ clip-path: xywh(0 20.42875157629256% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 29.508196721311474%);
+}
+
+.btn-advanced-construction-png{
+ clip-path: xywh(0 20.55485498108449% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 29.382093316519548%);
+}
+
+.btn-advanced-defensive-matrix-png{
+ clip-path: xywh(0 20.68095838587642% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 29.255989911727617%);
+}
+
+.btn-advanced-photon-blasters-png{
+ clip-path: xywh(0 20.80706179066835% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 29.129886506935687%);
+}
+
+.btn-advanced-targeting-png{
+ clip-path: xywh(0 20.933165195460276% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 29.003783102143757%);
+}
+
+.btn-afterburners-valkyrie-png{
+ clip-path: xywh(0 21.059268600252206% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 28.87767969735183%);
+}
+
+.btn-all-terrain-treads-png{
+ clip-path: xywh(0 21.185372005044137% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 28.7515762925599%);
+}
+
+.btn-amonshardsarmor-png{
+ clip-path: xywh(0 21.311475409836067% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 28.62547288776797%);
+}
+
+.btn-anti-surface-countermeasures-png{
+ clip-path: xywh(0 21.437578814627994% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 28.49936948297604%);
+}
+
+.btn-apial-sensors-png{
+ clip-path: xywh(0 21.563682219419924% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 28.373266078184113%);
+}
+
+.btn-arc-inducers-png{
+ clip-path: xywh(0 21.689785624211854% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 28.247162673392182%);
+}
+
+.btn-argus-talisman-png{
+ clip-path: xywh(0 21.815889029003785% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 28.121059268600252%);
+}
+
+.btn-armor-metling-blasters-png{
+ clip-path: xywh(0 21.94199243379571% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 27.994955863808322%);
+}
+
+.btn-atx-batteries-png{
+ clip-path: xywh(0 22.06809583858764% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 27.868852459016395%);
+}
+
+.btn-automated-mitosis-lvl1-png{
+ clip-path: xywh(0 22.194199243379572% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 27.742749054224465%);
+}
+
+.btn-banshee-cross-spectrum-dampeners-png{
+ clip-path: xywh(0 22.320302648171502% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 27.616645649432535%);
+}
+
+.btn-behemoth-stellarskin-png{
+ clip-path: xywh(0 22.44640605296343% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 27.490542244640604%);
+}
+
+.btn-blood-amulet-png{
+ clip-path: xywh(0 22.57250945775536% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 27.364438839848678%);
+}
+
+.btn-building-protoss-photoncannon-png{
+ clip-path: xywh(0 22.69861286254729% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 27.238335435056747%);
+}
+
+.btn-building-protoss-shieldbattery-png{
+ clip-path: xywh(0 22.82471626733922% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 27.112232030264817%);
+}
+
+.btn-building-stukov-infestedbunker-png{
+ clip-path: xywh(0 22.950819672131146% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 26.986128625472887%);
+}
+
+.btn-building-stukov-infestedturret-png{
+ clip-path: xywh(0 23.076923076923077% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 26.86002522068096%);
+}
+
+.btn-building-terran-autoturret-png{
+ clip-path: xywh(0 23.203026481715007% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 26.73392181588903%);
+}
+
+.btn-building-terran-bunker-png{
+ clip-path: xywh(0 23.329129886506937% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 26.6078184110971%);
+}
+
+.btn-building-terran-bunkerneosteel-png{
+ clip-path: xywh(0 23.455233291298864% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 26.48171500630517%);
+}
+
+.btn-building-terran-hivemindemulator-png{
+ clip-path: xywh(0 23.581336696090794% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 26.355611601513242%);
+}
+
+.btn-building-terran-missileturret-png{
+ clip-path: xywh(0 23.707440100882724% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 26.229508196721312%);
+}
+
+.btn-building-terran-planetaryfortress-png{
+ clip-path: xywh(0 23.833543505674655% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 26.103404791929382%);
+}
+
+.btn-building-terran-refineryautomated-png{
+ clip-path: xywh(0 23.95964691046658% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 25.97730138713745%);
+}
+
+.btn-building-terran-sensordome-png{
+ clip-path: xywh(0 24.08575031525851% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 25.851197982345525%);
+}
+
+.btn-building-terran-sigmaprojector-png{
+ clip-path: xywh(0 24.211853720050442% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 25.725094577553595%);
+}
+
+.btn-building-terran-techreactor-png{
+ clip-path: xywh(0 24.337957124842372% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 25.598991172761664%);
+}
+
+.btn-building-zerg-hive-png{
+ clip-path: xywh(0 24.4640605296343% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 25.472887767969734%);
+}
+
+.btn-building-zerg-nydusworm-png{
+ clip-path: xywh(0 24.59016393442623% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 25.346784363177807%);
+}
+
+.btn-building-zerg-spinecrawler-png{
+ clip-path: xywh(0 24.71626733921816% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 25.220680958385877%);
+}
+
+.btn-building-zerg-sporecannon-png{
+ clip-path: xywh(0 24.84237074401009% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 25.094577553593947%);
+}
+
+.btn-building-zerg-sporecrawler-png{
+ clip-path: xywh(0 24.968474148802017% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 24.968474148802017%);
+}
+
+.btn-caladrius-structure-png{
+ clip-path: xywh(0 25.094577553593947% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 24.84237074401009%);
+}
+
+.btn-chronostatic-reinforcement-png{
+ clip-path: xywh(0 25.220680958385877% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 24.71626733921816%);
+}
+
+.btn-command-cancel-png{
+ clip-path: xywh(0 25.346784363177807% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 24.59016393442623%);
+}
+
+.btn-concentrated-antimatter-png{
+ clip-path: xywh(0 25.472887767969734% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 24.4640605296343%);
+}
+
+.btn-disintegrating-particles-png{
+ clip-path: xywh(0 25.598991172761664% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 24.337957124842372%);
+}
+
+.btn-disruptor-dispersion-png{
+ clip-path: xywh(0 25.725094577553595% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 24.211853720050442%);
+}
+
+.btn-endless-servitude-png{
+ clip-path: xywh(0 25.851197982345525% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 24.08575031525851%);
+}
+
+.btn-enhanced-servo-striders-png{
+ clip-path: xywh(0 25.97730138713745% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 23.95964691046658%);
+}
+
+.btn-enhanced-shield-generator-png{
+ clip-path: xywh(0 26.103404791929382% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 23.833543505674655%);
+}
+
+.btn-eye-of-wrath-png{
+ clip-path: xywh(0 26.229508196721312% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 23.707440100882724%);
+}
+
+.btn-fire-suppression-system-lvl2-png{
+ clip-path: xywh(0 26.355611601513242% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 23.581336696090794%);
+}
+
+.btn-fleshfused-targeting-optics-png{
+ clip-path: xywh(0 26.48171500630517% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 23.455233291298864%);
+}
+
+.btn-forged-chassis-png{
+ clip-path: xywh(0 26.6078184110971% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 23.329129886506937%);
+}
+
+.btn-gaping-maw-png{
+ clip-path: xywh(0 26.73392181588903% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 23.203026481715007%);
+}
+
+.btn-gravitic-thrusters-png{
+ clip-path: xywh(0 26.86002522068096% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 23.076923076923077%);
+}
+
+.btn-high-explosive-munition-png{
+ clip-path: xywh(0 26.986128625472887% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 22.950819672131146%);
+}
+
+.btn-high-voltage-capacitors-png{
+ clip-path: xywh(0 27.112232030264817% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 22.82471626733922%);
+}
+
+.btn-hostile-environment-adaptation-png{
+ clip-path: xywh(0 27.238335435056747% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 22.69861286254729%);
+}
+
+.btn-hull-of-past-glories-png{
+ clip-path: xywh(0 27.364438839848678% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 22.57250945775536%);
+}
+
+.btn-hunter-seeker-weapon-png{
+ clip-path: xywh(0 27.490542244640604% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 22.44640605296343%);
+}
+
+.btn-iconic-wavelength-flux-png{
+ clip-path: xywh(0 27.616645649432535% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 22.320302648171502%);
+}
+
+.btn-improved-osmosis-png{
+ clip-path: xywh(0 27.742749054224465% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 22.194199243379572%);
+}
+
+.btn-infested-liberator-ag-png{
+ clip-path: xywh(0 27.868852459016395% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 22.06809583858764%);
+}
+
+.btn-integrated-power-png{
+ clip-path: xywh(0 27.994955863808322% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 21.94199243379571%);
+}
+
+.btn-jerry-rigged-patchjob-png{
+ clip-path: xywh(0 28.121059268600252% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 21.815889029003785%);
+}
+
+.btn-juggernaut-plating-herc-png{
+ clip-path: xywh(0 28.247162673392182% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 21.689785624211854%);
+}
+
+.btn-juggernaut-plating-marauder-png{
+ clip-path: xywh(0 28.373266078184113% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 21.563682219419924%);
+}
+
+.btn-jump-png{
+ clip-path: xywh(0 28.49936948297604% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 21.437578814627994%);
+}
+
+.btn-kryhas-cloak-png{
+ clip-path: xywh(0 28.62547288776797% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 21.311475409836067%);
+}
+
+.btn-latticed-shielding-png{
+ clip-path: xywh(0 28.7515762925599% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 21.185372005044137%);
+}
+
+.btn-launch-vector-compensator-png{
+ clip-path: xywh(0 28.87767969735183% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 21.059268600252206%);
+}
+
+.btn-lesser-shadow-fury-png{
+ clip-path: xywh(0 29.003783102143757% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 20.933165195460276%);
+}
+
+.btn-magellan-computation-systems-png{
+ clip-path: xywh(0 29.129886506935687% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 20.80706179066835%);
+}
+
+.btn-mobility-protocols-png{
+ clip-path: xywh(0 29.255989911727617% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 20.68095838587642%);
+}
+
+.btn-modernized-servos-png{
+ clip-path: xywh(0 29.382093316519548% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 20.55485498108449%);
+}
+
+.btn-moirai-impulse-drive-png{
+ clip-path: xywh(0 29.508196721311474% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 20.42875157629256%);
+}
+
+.btn-monstrous-resilience-aberration-png{
+ clip-path: xywh(0 29.634300126103405% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 20.302648171500632%);
+}
+
+.btn-monstrous-resilience-corruptor-png{
+ clip-path: xywh(0 29.760403530895335% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 20.1765447667087%);
+}
+
+.btn-neutron-shields-png{
+ clip-path: xywh(0 29.886506935687265% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 20.05044136191677%);
+}
+
+.btn-null-shroud-png{
+ clip-path: xywh(0 30.012610340479192% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 19.92433795712484%);
+}
+
+.btn-obliterate-png{
+ clip-path: xywh(0 30.138713745271122% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 19.798234552332914%);
+}
+
+.btn-orbital-fortress-png{
+ clip-path: xywh(0 30.264817150063053% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 19.672131147540984%);
+}
+
+.btn-pacification-protocols-png{
+ clip-path: xywh(0 30.390920554854983% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 19.546027742749054%);
+}
+
+.btn-peer-contempt-png{
+ clip-path: xywh(0 30.51702395964691% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 19.419924337957124%);
+}
+
+.btn-permacloak-banshee-png{
+ clip-path: xywh(0 30.64312736443884% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 19.293820933165197%);
+}
+
+.btn-permacloak-ghost-png{
+ clip-path: xywh(0 30.76923076923077% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 19.167717528373267%);
+}
+
+.btn-permacloak-medivac-png{
+ clip-path: xywh(0 30.8953341740227% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 19.041614123581336%);
+}
+
+.btn-permacloak-reaper-png{
+ clip-path: xywh(0 31.021437578814627% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 18.915510718789406%);
+}
+
+.btn-permacloak-spectre-png{
+ clip-path: xywh(0 31.147540983606557% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 18.78940731399748%);
+}
+
+.btn-permacloak-wraith-png{
+ clip-path: xywh(0 31.273644388398488% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 18.66330390920555%);
+}
+
+.btn-phase-blaster-png{
+ clip-path: xywh(0 31.399747793190418% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 18.53720050441362%);
+}
+
+.btn-phase-cloak-png{
+ clip-path: xywh(0 31.525851197982345% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 18.41109709962169%);
+}
+
+.btn-prescient-spores-png{
+ clip-path: xywh(0 31.651954602774275% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 18.284993694829762%);
+}
+
+.btn-progression-hornerhan-6-mirabuildtime-png{
+ clip-path: xywh(0 31.778058007566205% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 18.15889029003783%);
+}
+
+.btn-progression-protoss-fenix-1-zealotsuit-png{
+ clip-path: xywh(0 31.904161412358135% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 18.0327868852459%);
+}
+
+.btn-progression-protoss-fenix-6-forgeresearch-png{
+ clip-path: xywh(0 32.03026481715006% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 17.90668348045397%);
+}
+
+.btn-progression-zerg-dehaka-15-genemutation-png{
+ clip-path: xywh(0 32.156368221941996% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 17.780580075662044%);
+}
+
+.btn-progression-zerg-dehaka-7-newdehakaabilities-png{
+ clip-path: xywh(0 32.28247162673392% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 17.65447667087011%);
+}
+
+.btn-propellant-sacs-png{
+ clip-path: xywh(0 32.40857503152585% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 17.528373266078184%);
+}
+
+.btn-rapid-metamorph-png{
+ clip-path: xywh(0 32.53467843631778% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 17.402269861286257%);
+}
+
+.btn-regenerativebiosteel-blue-png{
+ clip-path: xywh(0 32.66078184110971% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 17.276166456494323%);
+}
+
+.btn-regenerativebiosteel-green-png{
+ clip-path: xywh(0 32.78688524590164% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 17.150063051702396%);
+}
+
+.btn-reintigrated-framework-png{
+ clip-path: xywh(0 32.91298865069357% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 17.02395964691047%);
+}
+
+.btn-research-terran-commandcenterreactor-png{
+ clip-path: xywh(0 33.0390920554855% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 16.897856242118536%);
+}
+
+.btn-research-terran-microfiltering-png{
+ clip-path: xywh(0 33.16519546027743% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 16.77175283732661%);
+}
+
+.btn-research-terran-orbitaldepots-png{
+ clip-path: xywh(0 33.29129886506936% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 16.645649432534675%);
+}
+
+.btn-research-terran-orbitalstrikerally-png{
+ clip-path: xywh(0 33.417402269861284% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 16.51954602774275%);
+}
+
+.btn-research-terran-ultracapacitors-png{
+ clip-path: xywh(0 33.54350567465322% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 16.393442622950822%);
+}
+
+.btn-research-terran-vanadiumplating-png{
+ clip-path: xywh(0 33.669609079445145% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 16.267339218158888%);
+}
+
+.btn-research-zerg-cellularreactor-png{
+ clip-path: xywh(0 33.79571248423707% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 16.14123581336696%);
+}
+
+.btn-research-zerg-fortifiedbunker-png{
+ clip-path: xywh(0 33.921815889029006% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 16.015132408575035%);
+}
+
+.btn-research-zerg-regenerativebio-steel-png{
+ clip-path: xywh(0 34.04791929382093% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 15.8890290037831%);
+}
+
+.btn-rogue-forces-png{
+ clip-path: xywh(0 34.17402269861286% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 15.762925598991174%);
+}
+
+.btn-royalliberator-png{
+ clip-path: xywh(0 34.30012610340479% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 15.63682219419924%);
+}
+
+.btn-scatter-veil-png{
+ clip-path: xywh(0 34.42622950819672% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 15.510718789407314%);
+}
+
+.btn-scv-cliffjump-png{
+ clip-path: xywh(0 34.55233291298865% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 15.384615384615387%);
+}
+
+.btn-seismic-sonar-png{
+ clip-path: xywh(0 34.67843631778058% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 15.258511979823453%);
+}
+
+.btn-shadow-guard-training-png{
+ clip-path: xywh(0 34.80453972257251% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 15.132408575031526%);
+}
+
+.btn-shield-capacity-png{
+ clip-path: xywh(0 34.93064312736444% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 15.0063051702396%);
+}
+
+.btn-side-missiles-png{
+ clip-path: xywh(0 35.05674653215637% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 14.880201765447666%);
+}
+
+.btn-skyward-chronoanomaly-png{
+ clip-path: xywh(0 35.182849936948294% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 14.754098360655739%);
+}
+
+.btn-solarite-lens-png{
+ clip-path: xywh(0 35.30895334174023% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 14.627994955863805%);
+}
+
+.btn-solarite-payload-png{
+ clip-path: xywh(0 35.435056746532155% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 14.501891551071878%);
+}
+
+.btn-stabilized-electrodes-png{
+ clip-path: xywh(0 35.56116015132409% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 14.375788146279952%);
+}
+
+.btn-sustaining-disruption-png{
+ clip-path: xywh(0 35.687263556116015% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 14.249684741488018%);
+}
+
+.btn-techupgrade-kinetic-foam-png{
+ clip-path: xywh(0 35.81336696090794% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 14.123581336696091%);
+}
+
+.btn-techupgrade-terran-cloakdistortionfield-color-png{
+ clip-path: xywh(0 35.939470365699876% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 13.997477931904164%);
+}
+
+.btn-techupgrade-terran-combatshield-color-png{
+ clip-path: xywh(0 36.0655737704918% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 13.87137452711223%);
+}
+
+.btn-techupgrade-terran-hellstormbatteries-color-png{
+ clip-path: xywh(0 36.19167717528373% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 13.745271122320304%);
+}
+
+.btn-techupgrade-terran-immortalityprotocol-color-png{
+ clip-path: xywh(0 36.31778058007566% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 13.61916771752837%);
+}
+
+.btn-techupgrade-terran-impalerrounds-color-png{
+ clip-path: xywh(0 36.44388398486759% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 13.493064312736443%);
+}
+
+.btn-techupgrade-terran-missilepods-color-level1-png{
+ clip-path: xywh(0 36.569987389659524% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 13.366960907944517%);
+}
+
+.btn-techupgrade-terran-ocularimplants-png{
+ clip-path: xywh(0 36.69609079445145% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 13.240857503152583%);
+}
+
+.btn-techupgrade-terran-psioniclash-color-png{
+ clip-path: xywh(0 36.82219419924338% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 13.114754098360656%);
+}
+
+.btn-techupgrade-terran-rapiddeployment-color-png{
+ clip-path: xywh(0 36.94829760403531% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 12.98865069356873%);
+}
+
+.btn-techupgrade-terran-shapedblast-color-png{
+ clip-path: xywh(0 37.07440100882724% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 12.862547288776796%);
+}
+
+.btn-techupgrade-terran-shapedhull-colored-png{
+ clip-path: xywh(0 37.200504413619164% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 12.736443883984869%);
+}
+
+.btn-techupgrade-terran-titaniumhousing-color-png{
+ clip-path: xywh(0 37.3266078184111% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 12.610340479192935%);
+}
+
+.btn-techupgrade-terran-tomahawkpowercell-color-png{
+ clip-path: xywh(0 37.452711223203025% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 12.484237074401008%);
+}
+
+.btn-techupgrade-terran-u238rounds-color-png{
+ clip-path: xywh(0 37.57881462799496% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 12.358133669609082%);
+}
+
+.btn-tips-armory-png{
+ clip-path: xywh(0 37.704918032786885% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 12.232030264817148%);
+}
+
+.btn-tips-flamingbetty-png{
+ clip-path: xywh(0 37.83102143757881% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 12.105926860025221%);
+}
+
+.btn-tips-laserdrillantiair-png{
+ clip-path: xywh(0 37.957124842370746% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 11.979823455233294%);
+}
+
+.btn-tips-terran-energynova-png{
+ clip-path: xywh(0 38.08322824716267% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 11.85372005044136%);
+}
+
+.btn-twilight-chassis-png{
+ clip-path: xywh(0 38.2093316519546% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 11.727616645649434%);
+}
+
+.btn-ued-rocketry-technology-png{
+ clip-path: xywh(0 38.33543505674653% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 11.6015132408575%);
+}
+
+.btn-ultrasonic-pulse-color-png{
+ clip-path: xywh(0 38.46153846153846% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 11.475409836065573%);
+}
+
+.btn-unit-biomechanicaldrone-png{
+ clip-path: xywh(0 38.587641866330394% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 11.349306431273646%);
+}
+
+.btn-unit-collection-primal-roachupgrade-png{
+ clip-path: xywh(0 38.71374527112232% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 11.223203026481713%);
+}
+
+.btn-unit-collection-primal-tyrannozor-png{
+ clip-path: xywh(0 38.83984867591425% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 11.097099621689786%);
+}
+
+.btn-unit-collection-probe-remastered-png{
+ clip-path: xywh(0 38.96595208070618% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 10.97099621689786%);
+}
+
+.btn-unit-collection-purifier-carrier-png{
+ clip-path: xywh(0 39.09205548549811% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 10.844892812105925%);
+}
+
+.btn-unit-collection-purifier-disruptor-png{
+ clip-path: xywh(0 39.218158890290034% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 10.718789407313999%);
+}
+
+.btn-unit-collection-purifier-immortal-png{
+ clip-path: xywh(0 39.34426229508197% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 10.592686002522065%);
+}
+
+.btn-unit-collection-taldarim-carrier-png{
+ clip-path: xywh(0 39.470365699873895% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 10.466582597730138%);
+}
+
+.btn-unit-collection-taldarim-phoenix-png{
+ clip-path: xywh(0 39.59646910466583% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 10.340479192938211%);
+}
+
+.btn-unit-collection-vikingfighter-covertops-png{
+ clip-path: xywh(0 39.722572509457756% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 10.214375788146278%);
+}
+
+.btn-unit-collection-wraith-junker-png{
+ clip-path: xywh(0 39.84867591424968% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 10.08827238335435%);
+}
+
+.btn-unit-hunterling-png{
+ clip-path: xywh(0 39.974779319041616% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 9.962168978562424%);
+}
+
+.btn-unit-infested-infestedmedic-png{
+ clip-path: xywh(0 40.10088272383354% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 9.83606557377049%);
+}
+
+.btn-unit-protoss-adept-purifier-png{
+ clip-path: xywh(0 40.22698612862547% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 9.709962168978564%);
+}
+
+.btn-unit-protoss-alarak-taldarim-supplicant-png{
+ clip-path: xywh(0 40.3530895334174% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 9.58385876418663%);
+}
+
+.btn-unit-protoss-arbiter-png{
+ clip-path: xywh(0 40.47919293820933% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 9.457755359394703%);
+}
+
+.btn-unit-protoss-archon-upgraded-png{
+ clip-path: xywh(0 40.605296343001264% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 9.331651954602776%);
+}
+
+.btn-unit-protoss-archon-png{
+ clip-path: xywh(0 40.73139974779319% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 9.205548549810842%);
+}
+
+.btn-unit-protoss-carrier-png{
+ clip-path: xywh(0 40.85750315258512% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 9.079445145018916%);
+}
+
+.btn-unit-protoss-colossus-taldarim-png{
+ clip-path: xywh(0 40.98360655737705% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 8.953341740226989%);
+}
+
+.btn-unit-protoss-colossus-png{
+ clip-path: xywh(0 41.10970996216898% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 8.827238335435055%);
+}
+
+.btn-unit-protoss-corsair-png{
+ clip-path: xywh(0 41.235813366960905% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 8.701134930643128%);
+}
+
+.btn-unit-protoss-darktemplar-aiur-png{
+ clip-path: xywh(0 41.36191677175284% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 8.575031525851195%);
+}
+
+.btn-unit-protoss-darktemplar-taldarim-png{
+ clip-path: xywh(0 41.488020176544765% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 8.448928121059268%);
+}
+
+.btn-unit-protoss-darktemplar-png{
+ clip-path: xywh(0 41.6141235813367% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 8.322824716267341%);
+}
+
+.btn-unit-protoss-dragoon-void-png{
+ clip-path: xywh(0 41.740226986128626% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 8.196721311475407%);
+}
+
+.btn-unit-protoss-fenix-png{
+ clip-path: xywh(0 41.86633039092055% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 8.07061790668348%);
+}
+
+.btn-unit-protoss-hightemplar-nerazim-png{
+ clip-path: xywh(0 41.992433795712486% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 7.944514501891554%);
+}
+
+.btn-unit-protoss-hightemplar-taldarim-png{
+ clip-path: xywh(0 42.11853720050441% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 7.81841109709962%);
+}
+
+.btn-unit-protoss-hightemplar-png{
+ clip-path: xywh(0 42.24464060529634% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 7.692307692307693%);
+}
+
+.btn-unit-protoss-immortal-nerazim-png{
+ clip-path: xywh(0 42.37074401008827% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 7.56620428751576%);
+}
+
+.btn-unit-protoss-immortal-taldarim-png{
+ clip-path: xywh(0 42.4968474148802% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 7.440100882723833%);
+}
+
+.btn-unit-protoss-immortal-png{
+ clip-path: xywh(0 42.622950819672134% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 7.313997477931906%);
+}
+
+.btn-unit-protoss-khaydarinmonolith-png{
+ clip-path: xywh(0 42.74905422446406% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 7.187894073139972%);
+}
+
+.btn-unit-protoss-mothership-taldarim-png{
+ clip-path: xywh(0 42.87515762925599% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 7.061790668348046%);
+}
+
+.btn-unit-protoss-observer-png{
+ clip-path: xywh(0 43.00126103404792% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 6.935687263556119%);
+}
+
+.btn-unit-protoss-oracle-png{
+ clip-path: xywh(0 43.12736443883985% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 6.809583858764185%);
+}
+
+.btn-unit-protoss-phoenix-purifier-png{
+ clip-path: xywh(0 43.253467843631775% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 6.683480453972258%);
+}
+
+.btn-unit-protoss-phoenix-png{
+ clip-path: xywh(0 43.37957124842371% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 6.5573770491803245%);
+}
+
+.btn-unit-protoss-probe-warpin-png{
+ clip-path: xywh(0 43.505674653215635% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 6.431273644388398%);
+}
+
+.btn-unit-protoss-probe-png{
+ clip-path: xywh(0 43.63177805800757% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 6.305170239596471%);
+}
+
+.btn-unit-protoss-reaver-png{
+ clip-path: xywh(0 43.757881462799496% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 6.179066834804537%);
+}
+
+.btn-unit-protoss-scout-png{
+ clip-path: xywh(0 43.88398486759142% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 6.0529634300126105%);
+}
+
+.btn-unit-protoss-scoutnerazim-png{
+ clip-path: xywh(0 44.010088272383356% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 5.926860025220684%);
+}
+
+.btn-unit-protoss-scoutpurifier-png{
+ clip-path: xywh(0 44.13619167717528% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 5.80075662042875%);
+}
+
+.btn-unit-protoss-scouttaldarim-png{
+ clip-path: xywh(0 44.26229508196721% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 5.674653215636823%);
+}
+
+.btn-unit-protoss-sentry-purifier-png{
+ clip-path: xywh(0 44.388398486759144% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 5.548549810844889%);
+}
+
+.btn-unit-protoss-sentry-taldarim-png{
+ clip-path: xywh(0 44.51450189155107% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 5.422446406052963%);
+}
+
+.btn-unit-protoss-sentry-png{
+ clip-path: xywh(0 44.640605296343004% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 5.296343001261036%);
+}
+
+.btn-unit-protoss-stalker-purifier-png{
+ clip-path: xywh(0 44.76670870113493% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 5.170239596469102%);
+}
+
+.btn-unit-protoss-stalker-taldarim-collection-ds-png{
+ clip-path: xywh(0 44.89281210592686% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 5.044136191677175%);
+}
+
+.btn-unit-protoss-stalker-png{
+ clip-path: xywh(0 45.01891551071879% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 4.918032786885249%);
+}
+
+.btn-unit-protoss-tempest-purifier-png{
+ clip-path: xywh(0 45.14501891551072% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 4.791929382093315%);
+}
+
+.btn-unit-protoss-voidray-purifier-png{
+ clip-path: xywh(0 45.271122320302645% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 4.665825977301388%);
+}
+
+.btn-unit-protoss-voidray-taldarim-png{
+ clip-path: xywh(0 45.39722572509458% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 4.539722572509454%);
+}
+
+.btn-unit-protoss-warpprism-png{
+ clip-path: xywh(0 45.523329129886505% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 4.413619167717528%);
+}
+
+.btn-unit-protoss-warpray-png{
+ clip-path: xywh(0 45.64943253467844% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 4.287515762925601%);
+}
+
+.btn-unit-protoss-zealot-nerazim-png{
+ clip-path: xywh(0 45.775535939470366% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 4.161412358133667%);
+}
+
+.btn-unit-protoss-zealot-purifier-png{
+ clip-path: xywh(0 45.90163934426229% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 4.03530895334174%);
+}
+
+.btn-unit-protoss-zealot-png{
+ clip-path: xywh(0 46.02774274905423% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 3.9092055485498136%);
+}
+
+.btn-unit-terran-autoturretblackops-png{
+ clip-path: xywh(0 46.15384615384615% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 3.78310214375788%);
+}
+
+.btn-unit-terran-banshee-mengsk-png{
+ clip-path: xywh(0 46.27994955863808% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 3.656998738965953%);
+}
+
+.btn-unit-terran-banshee-png{
+ clip-path: xywh(0 46.406052963430014% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 3.5308953341740192%);
+}
+
+.btn-unit-terran-bansheemercenary-png{
+ clip-path: xywh(0 46.53215636822194% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 3.4047919293820925%);
+}
+
+.btn-unit-terran-battlecruiser-png{
+ clip-path: xywh(0 46.658259773013874% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 3.278688524590166%);
+}
+
+.btn-unit-terran-battlecruiserloki-png{
+ clip-path: xywh(0 46.7843631778058% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 3.152585119798232%);
+}
+
+.btn-unit-terran-battlecruisermengsk-png{
+ clip-path: xywh(0 46.91046658259773% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 3.0264817150063053%);
+}
+
+.btn-unit-terran-cobra-png{
+ clip-path: xywh(0 47.03656998738966% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 2.9003783102143785%);
+}
+
+.btn-unit-terran-cyclone-png{
+ clip-path: xywh(0 47.16267339218159% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 2.7742749054224447%);
+}
+
+.btn-unit-terran-deathhead-png{
+ clip-path: xywh(0 47.288776796973515% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 2.648171500630518%);
+}
+
+.btn-unit-terran-firebat-png{
+ clip-path: xywh(0 47.41488020176545% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 2.522068095838584%);
+}
+
+.btn-unit-terran-firebatmercenary-png{
+ clip-path: xywh(0 47.540983606557376% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 2.3959646910466574%);
+}
+
+.btn-unit-terran-ghost-png{
+ clip-path: xywh(0 47.66708701134931% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 2.2698612862547307%);
+}
+
+.btn-unit-terran-ghostmengsk-png{
+ clip-path: xywh(0 47.793190416141236% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 2.143757881462797%);
+}
+
+.btn-unit-terran-goliath-mengsk-png{
+ clip-path: xywh(0 47.91929382093316% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 2.01765447667087%);
+}
+
+.btn-unit-terran-goliath-png{
+ clip-path: xywh(0 48.0453972257251% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 1.8915510718789434%);
+}
+
+.btn-unit-terran-goliathmercenary-png{
+ clip-path: xywh(0 48.17150063051702% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 1.7654476670870096%);
+}
+
+.btn-unit-terran-hellion-png{
+ clip-path: xywh(0 48.29760403530895% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 1.639344262295083%);
+}
+
+.btn-unit-terran-hellionbattlemode-png{
+ clip-path: xywh(0 48.423707440100884% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 1.513240857503149%);
+}
+
+.btn-unit-terran-herc-png{
+ clip-path: xywh(0 48.54981084489281% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 1.3871374527112224%);
+}
+
+.btn-unit-terran-hercules-png{
+ clip-path: xywh(0 48.675914249684745% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 1.2610340479192956%);
+}
+
+.btn-unit-terran-liberator-png{
+ clip-path: xywh(0 48.80201765447667% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 1.1349306431273618%);
+}
+
+.btn-unit-terran-liberatorblackops-png{
+ clip-path: xywh(0 48.9281210592686% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 1.008827238335435%);
+}
+
+.btn-unit-terran-marauder-png{
+ clip-path: xywh(0 49.05422446406053% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 0.8827238335435084%);
+}
+
+.btn-unit-terran-maraudermengsk-png{
+ clip-path: xywh(0 49.18032786885246% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 0.7566204287515745%);
+}
+
+.btn-unit-terran-maraudermercenary-png{
+ clip-path: xywh(0 49.306431273644385% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 0.6305170239596478%);
+}
+
+.btn-unit-terran-marine-mengsk-png{
+ clip-path: xywh(0 49.43253467843632% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 0.504413619167714%);
+}
+
+.btn-unit-terran-marine-png{
+ clip-path: xywh(0 49.558638083228246% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 0.37831021437578727%);
+}
+
+.btn-unit-terran-marinemercenary-png{
+ clip-path: xywh(0 49.68474148802018% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 0.25220680958386055%);
+}
+
+.btn-unit-terran-medic-mengsk-png{
+ clip-path: xywh(0 49.810844892812106% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 0.12610340479192672%);
+}
+
+.btn-unit-terran-medic-png{
+ clip-path: xywh(0 49.93694829760403% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, 0.0%);
+}
+
+.btn-unit-terran-medicelite-png{
+ clip-path: xywh(0 50.06305170239597% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -0.12610340479192672%);
+}
+
+.btn-unit-terran-medivac-png{
+ clip-path: xywh(0 50.189155107187894% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -0.25220680958386055%);
+}
+
+.btn-unit-terran-merc-thor-png{
+ clip-path: xywh(0 50.31525851197982% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -0.37831021437578727%);
+}
+
+.btn-unit-terran-mule-png{
+ clip-path: xywh(0 50.441361916771754% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -0.504413619167714%);
+}
+
+.btn-unit-terran-perditionturret-png{
+ clip-path: xywh(0 50.56746532156368% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -0.6305170239596478%);
+}
+
+.btn-unit-terran-predator-png{
+ clip-path: xywh(0 50.693568726355615% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -0.7566204287515745%);
+}
+
+.btn-unit-terran-raven-png{
+ clip-path: xywh(0 50.81967213114754% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -0.8827238335435084%);
+}
+
+.btn-unit-terran-reaper-png{
+ clip-path: xywh(0 50.94577553593947% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -1.008827238335435%);
+}
+
+.btn-unit-terran-sciencevessel-png{
+ clip-path: xywh(0 51.0718789407314% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -1.1349306431273618%);
+}
+
+.btn-unit-terran-siegetank-png{
+ clip-path: xywh(0 51.19798234552333% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -1.2610340479192956%);
+}
+
+.btn-unit-terran-siegetankmengsk-png{
+ clip-path: xywh(0 51.324085750315255% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -1.3871374527112224%);
+}
+
+.btn-unit-terran-siegetankmercenary-tank-png{
+ clip-path: xywh(0 51.45018915510719% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -1.513240857503149%);
+}
+
+.btn-unit-terran-spectre-png{
+ clip-path: xywh(0 51.576292559899116% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -1.639344262295083%);
+}
+
+.btn-unit-terran-thor-png{
+ clip-path: xywh(0 51.70239596469105% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -1.7654476670870096%);
+}
+
+.btn-unit-terran-thormengsk-png{
+ clip-path: xywh(0 51.82849936948298% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -1.8915510718789434%);
+}
+
+.btn-unit-terran-thorsiegemode-png{
+ clip-path: xywh(0 51.9546027742749% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -2.01765447667087%);
+}
+
+.btn-unit-terran-troopermengsk-png{
+ clip-path: xywh(0 52.08070617906684% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -2.143757881462797%);
+}
+
+.btn-unit-terran-valkyriescbw-png{
+ clip-path: xywh(0 52.206809583858764% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -2.2698612862547307%);
+}
+
+.btn-unit-terran-vikingfighter-png{
+ clip-path: xywh(0 52.33291298865069% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -2.3959646910466574%);
+}
+
+.btn-unit-terran-vikingmengskfighter-png{
+ clip-path: xywh(0 52.459016393442624% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -2.522068095838584%);
+}
+
+.btn-unit-terran-vikingmercenary-fighter-png{
+ clip-path: xywh(0 52.58511979823455% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -2.648171500630518%);
+}
+
+.btn-unit-terran-vulture-png{
+ clip-path: xywh(0 52.711223203026485% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -2.7742749054224447%);
+}
+
+.btn-unit-terran-warhound-png{
+ clip-path: xywh(0 52.83732660781841% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -2.9003783102143785%);
+}
+
+.btn-unit-terran-widowmine-png{
+ clip-path: xywh(0 52.96343001261034% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -3.0264817150063053%);
+}
+
+.btn-unit-terran-wraith-mengsk-png{
+ clip-path: xywh(0 53.08953341740227% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -3.152585119798232%);
+}
+
+.btn-unit-terran-wraith-png{
+ clip-path: xywh(0 53.2156368221942% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -3.278688524590166%);
+}
+
+.btn-unit-voidray-aiur-png{
+ clip-path: xywh(0 53.341740226986126% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -3.4047919293820925%);
+}
+
+.btn-unit-zerg-aberration-png{
+ clip-path: xywh(0 53.46784363177806% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -3.5308953341740192%);
+}
+
+.btn-unit-zerg-baneling-hunter-png{
+ clip-path: xywh(0 53.593947036569986% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -3.656998738965953%);
+}
+
+.btn-unit-zerg-baneling-png{
+ clip-path: xywh(0 53.72005044136192% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -3.78310214375788%);
+}
+
+.btn-unit-zerg-broodlord-png{
+ clip-path: xywh(0 53.84615384615385% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -3.9092055485498136%);
+}
+
+.btn-unit-zerg-broodqueen-png{
+ clip-path: xywh(0 53.97225725094577% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -4.03530895334174%);
+}
+
+.btn-unit-zerg-bullfrog-png{
+ clip-path: xywh(0 54.09836065573771% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -4.161412358133667%);
+}
+
+.btn-unit-zerg-classicqueen-png{
+ clip-path: xywh(0 54.224464060529634% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -4.287515762925601%);
+}
+
+.btn-unit-zerg-corruptor-png{
+ clip-path: xywh(0 54.35056746532156% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -4.413619167717528%);
+}
+
+.btn-unit-zerg-defilerscbw-png{
+ clip-path: xywh(0 54.476670870113495% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -4.539722572509454%);
+}
+
+.btn-unit-zerg-devourerex3-png{
+ clip-path: xywh(0 54.60277427490542% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -4.665825977301388%);
+}
+
+.btn-unit-zerg-hydralisk-remastered-png{
+ clip-path: xywh(0 54.728877679697355% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -4.791929382093315%);
+}
+
+.btn-unit-zerg-hydralisk-png{
+ clip-path: xywh(0 54.85498108448928% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -4.918032786885249%);
+}
+
+.btn-unit-zerg-impaler-png{
+ clip-path: xywh(0 54.98108448928121% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -5.044136191677175%);
+}
+
+.btn-unit-zerg-infestedbanshee-png{
+ clip-path: xywh(0 55.10718789407314% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -5.170239596469102%);
+}
+
+.btn-unit-zerg-infesteddiamondback-png{
+ clip-path: xywh(0 55.23329129886507% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -5.296343001261036%);
+}
+
+.btn-unit-zerg-infestedliberator-png{
+ clip-path: xywh(0 55.359394703656996% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -5.422446406052963%);
+}
+
+.btn-unit-zerg-infestedmarine-png{
+ clip-path: xywh(0 55.48549810844893% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -5.548549810844889%);
+}
+
+.btn-unit-zerg-infestedsiegetank-png{
+ clip-path: xywh(0 55.611601513240856% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -5.674653215636823%);
+}
+
+.btn-unit-zerg-infestor-png{
+ clip-path: xywh(0 55.73770491803279% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -5.80075662042875%);
+}
+
+.btn-unit-zerg-kerriganascended-png{
+ clip-path: xywh(0 55.86380832282472% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -5.926860025220684%);
+}
+
+.btn-unit-zerg-kerriganghost-png{
+ clip-path: xywh(0 55.989911727616644% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -6.0529634300126105%);
+}
+
+.btn-unit-zerg-kerriganinfested-png{
+ clip-path: xywh(0 56.11601513240858% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -6.179066834804537%);
+}
+
+.btn-unit-zerg-larva-png{
+ clip-path: xywh(0 56.242118537200504% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -6.305170239596471%);
+}
+
+.btn-unit-zerg-leviathan-png{
+ clip-path: xywh(0 56.36822194199243% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -6.431273644388398%);
+}
+
+.btn-unit-zerg-lurker-png{
+ clip-path: xywh(0 56.494325346784365% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -6.5573770491803245%);
+}
+
+.btn-unit-zerg-mutalisk-png{
+ clip-path: xywh(0 56.62042875157629% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -6.683480453972258%);
+}
+
+.btn-unit-zerg-nydusdragon-png{
+ clip-path: xywh(0 56.746532156368225% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -6.809583858764185%);
+}
+
+.btn-unit-zerg-overlordscbw-png{
+ clip-path: xywh(0 56.87263556116015% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -6.935687263556119%);
+}
+
+.btn-unit-zerg-overseer-png{
+ clip-path: xywh(0 56.99873896595208% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -7.061790668348046%);
+}
+
+.btn-unit-zerg-primalguardian-png{
+ clip-path: xywh(0 57.12484237074401% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -7.187894073139972%);
+}
+
+.btn-unit-zerg-ravager-png{
+ clip-path: xywh(0 57.25094577553594% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -7.313997477931906%);
+}
+
+.btn-unit-zerg-roach-corpser-png{
+ clip-path: xywh(0 57.377049180327866% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -7.440100882723833%);
+}
+
+.btn-unit-zerg-roach-vile-png{
+ clip-path: xywh(0 57.5031525851198% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -7.56620428751576%);
+}
+
+.btn-unit-zerg-roach-png{
+ clip-path: xywh(0 57.62925598991173% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -7.692307692307693%);
+}
+
+.btn-unit-zerg-roach_collection-png{
+ clip-path: xywh(0 57.75535939470366% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -7.81841109709962%);
+}
+
+.btn-unit-zerg-scourge-png{
+ clip-path: xywh(0 57.88146279949559% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -7.944514501891554%);
+}
+
+.btn-unit-zerg-swarmhost-carrion-png{
+ clip-path: xywh(0 58.007566204287514% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -8.07061790668348%);
+}
+
+.btn-unit-zerg-swarmhost-creeper-png{
+ clip-path: xywh(0 58.13366960907945% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -8.196721311475407%);
+}
+
+.btn-unit-zerg-swarmhost-png{
+ clip-path: xywh(0 58.259773013871374% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -8.322824716267341%);
+}
+
+.btn-unit-zerg-ultralisk-noxious-png{
+ clip-path: xywh(0 58.3858764186633% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -8.448928121059268%);
+}
+
+.btn-unit-zerg-ultralisk-rcz-png{
+ clip-path: xywh(0 58.511979823455235% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -8.575031525851195%);
+}
+
+.btn-unit-zerg-ultralisk-remastered-png{
+ clip-path: xywh(0 58.63808322824716% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -8.701134930643128%);
+}
+
+.btn-unit-zerg-ultralisk-torrasque-png{
+ clip-path: xywh(0 58.764186633039095% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -8.827238335435055%);
+}
+
+.btn-unit-zerg-ultralisk-png{
+ clip-path: xywh(0 58.89029003783102% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -8.953341740226989%);
+}
+
+.btn-unit-zerg-viper-png{
+ clip-path: xywh(0 59.01639344262295% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -9.079445145018916%);
+}
+
+.btn-unit-zerg-zergling-raptor-png{
+ clip-path: xywh(0 59.14249684741488% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -9.205548549810842%);
+}
+
+.btn-unit-zerg-zergling-scr-png{
+ clip-path: xywh(0 59.26860025220681% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -9.331651954602776%);
+}
+
+.btn-unit-zerg-zergling-swarmling-png{
+ clip-path: xywh(0 59.394703656998736% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -9.457755359394703%);
+}
+
+.btn-unit-zerg-zergling-png{
+ clip-path: xywh(0 59.52080706179067% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -9.58385876418663%);
+}
+
+.btn-unshackled-psionic-storm-png{
+ clip-path: xywh(0 59.6469104665826% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -9.709962168978564%);
+}
+
+.btn-upgrade-afaidofthedark-png{
+ clip-path: xywh(0 59.77301387137453% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -9.83606557377049%);
+}
+
+.btn-upgrade-artanis-healingpsionicstorm-png{
+ clip-path: xywh(0 59.89911727616646% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -9.962168978562424%);
+}
+
+.btn-upgrade-artanis-scarabsplashradius-png{
+ clip-path: xywh(0 60.025220680958384% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -10.08827238335435%);
+}
+
+.btn-upgrade-artanis-singularitycharge-png{
+ clip-path: xywh(0 60.15132408575032% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -10.214375788146278%);
+}
+
+.btn-upgrade-custom-triple-scourge-png{
+ clip-path: xywh(0 60.277427490542244% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -10.340479192938211%);
+}
+
+.btn-upgrade-increasedupgraderesearchspeed-png{
+ clip-path: xywh(0 60.40353089533417% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -10.466582597730138%);
+}
+
+.btn-upgrade-karax-energyregen200-png{
+ clip-path: xywh(0 60.529634300126105% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -10.592686002522065%);
+}
+
+.btn-upgrade-karax-pylonwarpininstantly-png{
+ clip-path: xywh(0 60.65573770491803% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -10.718789407313999%);
+}
+
+.btn-upgrade-karax-turretattackspeed-png{
+ clip-path: xywh(0 60.781841109709966% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -10.844892812105925%);
+}
+
+.btn-upgrade-karax-turretrange-png{
+ clip-path: xywh(0 60.90794451450189% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -10.97099621689786%);
+}
+
+.btn-upgrade-kerrigan-assimilationaura-png{
+ clip-path: xywh(0 61.03404791929382% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -11.097099621689786%);
+}
+
+.btn-upgrade-kerrigan-broodlordspeed-png{
+ clip-path: xywh(0 61.16015132408575% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -11.223203026481713%);
+}
+
+.btn-upgrade-kerrigan-crushinggripwave-png{
+ clip-path: xywh(0 61.28625472887768% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -11.349306431273646%);
+}
+
+.btn-upgrade-kerrigan-seismicspines-png{
+ clip-path: xywh(0 61.412358133669606% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -11.475409836065573%);
+}
+
+.btn-upgrade-mengsk-engineeringbay-dominionarmorlevel2-png{
+ clip-path: xywh(0 61.53846153846154% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -11.6015132408575%);
+}
+
+.btn-upgrade-mengsk-engineeringbay-dominionweaponslevel0-png{
+ clip-path: xywh(0 61.66456494325347% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -11.727616645649434%);
+}
+
+.btn-upgrade-mengsk-engineeringbay-neosteelfortifiedarmor-png{
+ clip-path: xywh(0 61.7906683480454% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -11.85372005044136%);
+}
+
+.btn-upgrade-mengsk-engineeringbay-orbitaldrop-png{
+ clip-path: xywh(0 61.91677175283733% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -11.979823455233294%);
+}
+
+.btn-upgrade-mengsk-ghostacademy-guidedtacticalstrike-png{
+ clip-path: xywh(0 62.042875157629254% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -12.105926860025221%);
+}
+
+.btn-upgrade-mengsk-trooper-flamethrower-png{
+ clip-path: xywh(0 62.16897856242119% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -12.232030264817148%);
+}
+
+.btn-upgrade-mengsk-trooper-missilelauncher-png{
+ clip-path: xywh(0 62.295081967213115% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -12.358133669609082%);
+}
+
+.btn-upgrade-mengsk-trooper-plasmarifle-png{
+ clip-path: xywh(0 62.42118537200504% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -12.484237074401008%);
+}
+
+.btn-upgrade-nova-blink-png{
+ clip-path: xywh(0 62.547288776796975% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -12.610340479192935%);
+}
+
+.btn-upgrade-nova-btn-upgrade-nova-flashgrenade-png{
+ clip-path: xywh(0 62.6733921815889% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -12.736443883984869%);
+}
+
+.btn-upgrade-nova-btn-upgrade-nova-pulsegrenade-png{
+ clip-path: xywh(0 62.799495586380836% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -12.862547288776796%);
+}
+
+.btn-upgrade-nova-equipment-apolloinfantrysuit-png{
+ clip-path: xywh(0 62.92559899117276% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -12.98865069356873%);
+}
+
+.btn-upgrade-nova-equipment-blinksuit-png{
+ clip-path: xywh(0 63.05170239596469% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -13.114754098360656%);
+}
+
+.btn-upgrade-nova-equipment-canisterrifle-png{
+ clip-path: xywh(0 63.17780580075662% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -13.240857503152583%);
+}
+
+.btn-upgrade-nova-equipment-ghostvisor-png{
+ clip-path: xywh(0 63.30390920554855% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -13.366960907944517%);
+}
+
+.btn-upgrade-nova-equipment-gunblade_sword-png{
+ clip-path: xywh(0 63.430012610340476% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -13.493064312736443%);
+}
+
+.btn-upgrade-nova-equipment-monomolecularblade-png{
+ clip-path: xywh(0 63.55611601513241% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -13.61916771752837%);
+}
+
+.btn-upgrade-nova-equipment-plasmagun-png{
+ clip-path: xywh(0 63.68221941992434% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -13.745271122320304%);
+}
+
+.btn-upgrade-nova-equipment-rangefinderoculus-png{
+ clip-path: xywh(0 63.80832282471627% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -13.87137452711223%);
+}
+
+.btn-upgrade-nova-equipment-shotgun-png{
+ clip-path: xywh(0 63.9344262295082% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -13.997477931904164%);
+}
+
+.btn-upgrade-nova-equipment-stealthsuit-png{
+ clip-path: xywh(0 64.06052963430012% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -14.123581336696091%);
+}
+
+.btn-upgrade-nova-holographicdecoy-png{
+ clip-path: xywh(0 64.18663303909206% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -14.249684741488025%);
+}
+
+.btn-upgrade-nova-jetpack-png{
+ clip-path: xywh(0 64.31273644388399% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -14.375788146279945%);
+}
+
+.btn-upgrade-nova-tacticalstealthsuit-png{
+ clip-path: xywh(0 64.43883984867591% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -14.501891551071878%);
+}
+
+.btn-upgrade-protoss-adeptshieldupgrade-png{
+ clip-path: xywh(0 64.56494325346785% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -14.627994955863812%);
+}
+
+.btn-upgrade-protoss-airarmorlevel1-png{
+ clip-path: xywh(0 64.69104665825978% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -14.754098360655732%);
+}
+
+.btn-upgrade-protoss-airarmorlevel2-png{
+ clip-path: xywh(0 64.8171500630517% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -14.880201765447666%);
+}
+
+.btn-upgrade-protoss-airarmorlevel3-png{
+ clip-path: xywh(0 64.94325346784363% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -15.0063051702396%);
+}
+
+.btn-upgrade-protoss-airarmorlevel4-png{
+ clip-path: xywh(0 65.06935687263557% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -15.13240857503152%);
+}
+
+.btn-upgrade-protoss-airarmorlevel5-png{
+ clip-path: xywh(0 65.19546027742749% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -15.258511979823453%);
+}
+
+.btn-upgrade-protoss-airweaponslevel1-png{
+ clip-path: xywh(0 65.32156368221942% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -15.384615384615387%);
+}
+
+.btn-upgrade-protoss-airweaponslevel2-png{
+ clip-path: xywh(0 65.44766708701135% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -15.51071878940732%);
+}
+
+.btn-upgrade-protoss-airweaponslevel3-png{
+ clip-path: xywh(0 65.57377049180327% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -15.63682219419924%);
+}
+
+.btn-upgrade-protoss-airweaponslevel4-png{
+ clip-path: xywh(0 65.69987389659521% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -15.762925598991174%);
+}
+
+.btn-upgrade-protoss-airweaponslevel5-png{
+ clip-path: xywh(0 65.82597730138714% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -15.889029003783108%);
+}
+
+.btn-upgrade-protoss-alarak-ascendantspsiorbtravelsfurther-png{
+ clip-path: xywh(0 65.95208070617906% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -16.015132408575028%);
+}
+
+.btn-upgrade-protoss-alarak-ascendantspermanentlybetter-png{
+ clip-path: xywh(0 66.078184110971% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -16.14123581336696%);
+}
+
+.btn-upgrade-protoss-alarak-graviticdrive-png{
+ clip-path: xywh(0 66.20428751576293% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -16.267339218158895%);
+}
+
+.btn-upgrade-protoss-alarak-havoctargetlockbuffed-png{
+ clip-path: xywh(0 66.33039092055486% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -16.393442622950815%);
+}
+
+.btn-upgrade-protoss-alarak-melleeweapon-png{
+ clip-path: xywh(0 66.45649432534678% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -16.51954602774275%);
+}
+
+.btn-upgrade-protoss-alarak-permanentcloak-png{
+ clip-path: xywh(0 66.58259773013872% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -16.645649432534682%);
+}
+
+.btn-upgrade-protoss-alarak-rangeincrease-png{
+ clip-path: xywh(0 66.70870113493065% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -16.771752837326602%);
+}
+
+.btn-upgrade-protoss-alarak-rangeweapon-png{
+ clip-path: xywh(0 66.83480453972257% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -16.897856242118536%);
+}
+
+.btn-upgrade-protoss-alarak-supplicantarmor-png{
+ clip-path: xywh(0 66.9609079445145% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -17.02395964691047%);
+}
+
+.btn-upgrade-protoss-alarak-supplicantextrashields-png{
+ clip-path: xywh(0 67.08701134930644% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -17.15006305170239%);
+}
+
+.btn-upgrade-protoss-fenix-adept-recochetglaiveupgraded-png{
+ clip-path: xywh(0 67.21311475409836% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -17.276166456494323%);
+}
+
+.btn-upgrade-protoss-fenix-adeptchampionbounceattack-png{
+ clip-path: xywh(0 67.33921815889029% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -17.402269861286257%);
+}
+
+.btn-upgrade-protoss-fenix-carrier-solarbeam-png{
+ clip-path: xywh(0 67.46532156368222% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -17.52837326607819%);
+}
+
+.btn-upgrade-protoss-fenix-disruptorpermanentcloak-png{
+ clip-path: xywh(0 67.59142496847414% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -17.65447667087011%);
+}
+
+.btn-upgrade-protoss-fenix-dragoonsolariteflare-png{
+ clip-path: xywh(0 67.71752837326608% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -17.780580075662044%);
+}
+
+.btn-upgrade-protoss-fenix-scoutchampionrange-png{
+ clip-path: xywh(0 67.84363177805801% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -17.906683480453978%);
+}
+
+.btn-upgrade-protoss-fenix-stasisfield-png{
+ clip-path: xywh(0 67.96973518284993% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -18.032786885245898%);
+}
+
+.btn-upgrade-protoss-fenix-zealotsuit-armorplate-png{
+ clip-path: xywh(0 68.09583858764186% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -18.15889029003783%);
+}
+
+.btn-upgrade-protoss-fluxvanes-png{
+ clip-path: xywh(0 68.2219419924338% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -18.284993694829765%);
+}
+
+.btn-upgrade-protoss-graviticbooster-png{
+ clip-path: xywh(0 68.34804539722572% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -18.411097099621685%);
+}
+
+.btn-upgrade-protoss-graviticdrive-png{
+ clip-path: xywh(0 68.47414880201765% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -18.53720050441362%);
+}
+
+.btn-upgrade-protoss-gravitoncatapult-png{
+ clip-path: xywh(0 68.60025220680959% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -18.663303909205553%);
+}
+
+.btn-upgrade-protoss-groundarmorlevel1-png{
+ clip-path: xywh(0 68.72635561160152% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -18.789407313997472%);
+}
+
+.btn-upgrade-protoss-groundarmorlevel2-png{
+ clip-path: xywh(0 68.85245901639344% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -18.915510718789406%);
+}
+
+.btn-upgrade-protoss-groundarmorlevel3-png{
+ clip-path: xywh(0 68.97856242118537% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -19.04161412358134%);
+}
+
+.btn-upgrade-protoss-groundarmorlevel4-png{
+ clip-path: xywh(0 69.1046658259773% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -19.16771752837326%);
+}
+
+.btn-upgrade-protoss-groundarmorlevel5-png{
+ clip-path: xywh(0 69.23076923076923% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -19.293820933165193%);
+}
+
+.btn-upgrade-protoss-groundweaponslevel1-png{
+ clip-path: xywh(0 69.35687263556116% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -19.419924337957127%);
+}
+
+.btn-upgrade-protoss-groundweaponslevel2-png{
+ clip-path: xywh(0 69.4829760403531% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -19.54602774274906%);
+}
+
+.btn-upgrade-protoss-groundweaponslevel3-png{
+ clip-path: xywh(0 69.60907944514501% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -19.67213114754098%);
+}
+
+.btn-upgrade-protoss-groundweaponslevel4-png{
+ clip-path: xywh(0 69.73518284993695% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -19.798234552332914%);
+}
+
+.btn-upgrade-protoss-groundweaponslevel5-png{
+ clip-path: xywh(0 69.86128625472888% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -19.92433795712485%);
+}
+
+.btn-upgrade-protoss-increasedscarabcapacityscbw-png{
+ clip-path: xywh(0 69.9873896595208% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -20.050441361916768%);
+}
+
+.btn-upgrade-protoss-khaydarinamulet-png{
+ clip-path: xywh(0 70.11349306431273% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -20.1765447667087%);
+}
+
+.btn-upgrade-protoss-phoenixrange-png{
+ clip-path: xywh(0 70.23959646910467% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -20.302648171500635%);
+}
+
+.btn-upgrade-protoss-researchbosoniccore-png{
+ clip-path: xywh(0 70.36569987389659% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -20.428751576292555%);
+}
+
+.btn-upgrade-protoss-researchgravitysling-png{
+ clip-path: xywh(0 70.49180327868852% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -20.55485498108449%);
+}
+
+.btn-upgrade-protoss-resonatingglaives-png{
+ clip-path: xywh(0 70.61790668348046% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -20.680958385876423%);
+}
+
+.btn-upgrade-protoss-shieldslevel1-png{
+ clip-path: xywh(0 70.74401008827239% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -20.807061790668342%);
+}
+
+.btn-upgrade-protoss-shieldslevel2-png{
+ clip-path: xywh(0 70.87011349306431% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -20.933165195460276%);
+}
+
+.btn-upgrade-protoss-shieldslevel3-png{
+ clip-path: xywh(0 70.99621689785624% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -21.05926860025221%);
+}
+
+.btn-upgrade-protoss-shieldslevel4-png{
+ clip-path: xywh(0 71.12232030264818% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -21.18537200504413%);
+}
+
+.btn-upgrade-protoss-shieldslevel5-png{
+ clip-path: xywh(0 71.2484237074401% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -21.311475409836063%);
+}
+
+.btn-upgrade-protoss-stalkerpurifier-reconstruction-png{
+ clip-path: xywh(0 71.37452711223203% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -21.437578814627997%);
+}
+
+.btn-upgrade-protoss-tectonicdisruptors-png{
+ clip-path: xywh(0 71.50063051702396% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -21.56368221941993%);
+}
+
+.btn-upgrade-protoss-vanguard-aoeradiusincreased-png{
+ clip-path: xywh(0 71.62673392181588% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -21.68978562421185%);
+}
+
+.btn-upgrade-protoss-vanguard-increasedarmordamage-png{
+ clip-path: xywh(0 71.75283732660782% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -21.815889029003785%);
+}
+
+.btn-upgrade-protoss-wrathwalker-cantargetairunits-png{
+ clip-path: xywh(0 71.87894073139975% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -21.94199243379572%);
+}
+
+.btn-upgrade-protoss-wrathwalker-chargetimeimproved-png{
+ clip-path: xywh(0 72.00504413619167% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -22.068095838587638%);
+}
+
+.btn-upgrade-psi-indoctrinator-png{
+ clip-path: xywh(0 72.1311475409836% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -22.194199243379572%);
+}
+
+.btn-upgrade-raynor-cerberusmines-png{
+ clip-path: xywh(0 72.25725094577554% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -22.320302648171506%);
+}
+
+.btn-upgrade-raynor-improvedsiegemode-png{
+ clip-path: xywh(0 72.38335435056746% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -22.446406052963425%);
+}
+
+.btn-upgrade-raynor-incineratorgauntlets-png{
+ clip-path: xywh(0 72.50945775535939% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -22.57250945775536%);
+}
+
+.btn-upgrade-raynor-juggernautplating-png{
+ clip-path: xywh(0 72.63556116015133% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -22.698612862547293%);
+}
+
+.btn-upgrade-raynor-maelstromrounds-png{
+ clip-path: xywh(0 72.76166456494326% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -22.824716267339213%);
+}
+
+.btn-upgrade-raynor-phobosclassweaponssystem-png{
+ clip-path: xywh(0 72.88776796973518% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -22.950819672131146%);
+}
+
+.btn-upgrade-raynor-replenishablemagazine-png{
+ clip-path: xywh(0 73.01387137452711% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -23.07692307692308%);
+}
+
+.btn-upgrade-raynor-ripwavemissiles-png{
+ clip-path: xywh(0 73.13997477931905% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -23.203026481715%);
+}
+
+.btn-upgrade-raynor-shockwavemissilebattery-png{
+ clip-path: xywh(0 73.26607818411097% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -23.329129886506934%);
+}
+
+.btn-upgrade-raynor-stabilizermedpacks-png{
+ clip-path: xywh(0 73.3921815889029% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -23.455233291298867%);
+}
+
+.btn-upgrade-reducedupgraderesearchcost-png{
+ clip-path: xywh(0 73.51828499369483% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -23.5813366960908%);
+}
+
+.btn-upgrade-siegetank-spidermines-png{
+ clip-path: xywh(0 73.64438839848675% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -23.70744010088272%);
+}
+
+.btn-upgrade-stetmann-banelingmanashieldefficiency-png{
+ clip-path: xywh(0 73.77049180327869% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -23.833543505674655%);
+}
+
+.btn-upgrade-stetmann-mechachitinousplating-png{
+ clip-path: xywh(0 73.89659520807062% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -23.95964691046659%);
+}
+
+.btn-upgrade-stetmann-zerglinghardenedshield-png{
+ clip-path: xywh(0 74.02269861286254% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -24.085750315258508%);
+}
+
+.btn-upgrade-swann-aresclasstargetingsystem-png{
+ clip-path: xywh(0 74.14880201765448% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -24.211853720050442%);
+}
+
+.btn-upgrade-swann-defensivematrix-png{
+ clip-path: xywh(0 74.27490542244641% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -24.337957124842376%);
+}
+
+.btn-upgrade-swann-displacementfield-png{
+ clip-path: xywh(0 74.40100882723833% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -24.464060529634295%);
+}
+
+.btn-upgrade-swann-firesuppressionsystem-png{
+ clip-path: xywh(0 74.52711223203026% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -24.59016393442623%);
+}
+
+.btn-upgrade-swann-hellarmor-png{
+ clip-path: xywh(0 74.6532156368222% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -24.716267339218163%);
+}
+
+.btn-upgrade-swann-improvedburstlaser-png{
+ clip-path: xywh(0 74.77931904161413% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -24.842370744010083%);
+}
+
+.btn-upgrade-swann-improvednanorepair-png{
+ clip-path: xywh(0 74.90542244640605% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -24.968474148802017%);
+}
+
+.btn-upgrade-swann-improvedturretattackspeed-png{
+ clip-path: xywh(0 75.03152585119798% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -25.09457755359395%);
+}
+
+.btn-upgrade-swann-multilockweaponsystem-png{
+ clip-path: xywh(0 75.15762925598992% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -25.22068095838587%);
+}
+
+.btn-upgrade-swann-scvdoublerepair-png{
+ clip-path: xywh(0 75.28373266078184% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -25.346784363177804%);
+}
+
+.btn-upgrade-swann-targetingoptics-png{
+ clip-path: xywh(0 75.40983606557377% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -25.472887767969738%);
+}
+
+.btn-upgrade-swann-vehiclerangeincrease-png{
+ clip-path: xywh(0 75.5359394703657% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -25.59899117276167%);
+}
+
+.btn-upgrade-terran-advanceballistics-png{
+ clip-path: xywh(0 75.66204287515762% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -25.72509457755359%);
+}
+
+.btn-upgrade-terran-behemothreactor-png{
+ clip-path: xywh(0 75.78814627994956% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -25.851197982345525%);
+}
+
+.btn-upgrade-terran-buildingarmor-png{
+ clip-path: xywh(0 75.91424968474149% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -25.97730138713746%);
+}
+
+.btn-upgrade-terran-cyclonerangeupgrade-png{
+ clip-path: xywh(0 76.04035308953341% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -26.10340479192938%);
+}
+
+.btn-upgrade-terran-durablematerials-png{
+ clip-path: xywh(0 76.16645649432535% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -26.229508196721312%);
+}
+
+.btn-upgrade-terran-highcapacityfueltanks-png{
+ clip-path: xywh(0 76.29255989911728% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -26.355611601513246%);
+}
+
+.btn-upgrade-terran-hisecautotracking-png{
+ clip-path: xywh(0 76.4186633039092% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -26.481715006305166%);
+}
+
+.btn-upgrade-terran-hyperflightrotors-png{
+ clip-path: xywh(0 76.54476670870113% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -26.6078184110971%);
+}
+
+.btn-upgrade-terran-infantryarmorlevel1-png{
+ clip-path: xywh(0 76.67087011349307% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -26.733921815889033%);
+}
+
+.btn-upgrade-terran-infantryarmorlevel2-png{
+ clip-path: xywh(0 76.796973518285% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -26.860025220680953%);
+}
+
+.btn-upgrade-terran-infantryarmorlevel3-png{
+ clip-path: xywh(0 76.92307692307692% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -26.986128625472887%);
+}
+
+.btn-upgrade-terran-infantryarmorlevel4-png{
+ clip-path: xywh(0 77.04918032786885% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -27.11223203026482%);
+}
+
+.btn-upgrade-terran-infantryarmorlevel5-png{
+ clip-path: xywh(0 77.17528373266079% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -27.23833543505674%);
+}
+
+.btn-upgrade-terran-infantryweaponslevel1-png{
+ clip-path: xywh(0 77.3013871374527% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -27.364438839848674%);
+}
+
+.btn-upgrade-terran-infantryweaponslevel2-png{
+ clip-path: xywh(0 77.42749054224464% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -27.490542244640608%);
+}
+
+.btn-upgrade-terran-infantryweaponslevel3-png{
+ clip-path: xywh(0 77.55359394703657% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -27.61664564943254%);
+}
+
+.btn-upgrade-terran-infantryweaponslevel4-png{
+ clip-path: xywh(0 77.6796973518285% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -27.74274905422446%);
+}
+
+.btn-upgrade-terran-infantryweaponslevel5-png{
+ clip-path: xywh(0 77.80580075662043% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -27.868852459016395%);
+}
+
+.btn-upgrade-terran-infernalpreigniter-png{
+ clip-path: xywh(0 77.93190416141236% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -27.99495586380833%);
+}
+
+.btn-upgrade-terran-interferencematrix-png{
+ clip-path: xywh(0 78.05800756620428% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -28.12105926860025%);
+}
+
+.btn-upgrade-terran-internalizedtechmodule-png{
+ clip-path: xywh(0 78.18411097099622% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -28.247162673392182%);
+}
+
+.btn-upgrade-terran-jumpjets-png{
+ clip-path: xywh(0 78.31021437578815% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -28.373266078184116%);
+}
+
+.btn-upgrade-terran-kd8chargeex3-png{
+ clip-path: xywh(0 78.43631778058007% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -28.499369482976036%);
+}
+
+.btn-upgrade-terran-lazertargetingsystem-png{
+ clip-path: xywh(0 78.562421185372% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -28.62547288776797%);
+}
+
+.btn-upgrade-terran-magfieldaccelerator-png{
+ clip-path: xywh(0 78.68852459016394% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -28.751576292559903%);
+}
+
+.btn-upgrade-terran-magrailmunitions-png{
+ clip-path: xywh(0 78.81462799495587% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -28.877679697351823%);
+}
+
+.btn-upgrade-terran-medivacemergencythrusters-png{
+ clip-path: xywh(0 78.94073139974779% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -29.003783102143757%);
+}
+
+.btn-upgrade-terran-neosteelframe-png{
+ clip-path: xywh(0 79.06683480453972% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -29.12988650693569%);
+}
+
+.btn-upgrade-terran-nova-bansheemissilestrik-png{
+ clip-path: xywh(0 79.19293820933166% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -29.25598991172761%);
+}
+
+.btn-upgrade-terran-nova-hellfiremissiles-png{
+ clip-path: xywh(0 79.31904161412358% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -29.382093316519544%);
+}
+
+.btn-upgrade-terran-nova-personaldefensivematrix-png{
+ clip-path: xywh(0 79.44514501891551% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -29.508196721311478%);
+}
+
+.btn-upgrade-terran-nova-siegetankrange-png{
+ clip-path: xywh(0 79.57124842370744% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -29.634300126103412%);
+}
+
+.btn-upgrade-terran-nova-specialordance-png{
+ clip-path: xywh(0 79.69735182849936% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -29.76040353089533%);
+}
+
+.btn-upgrade-terran-nova-terrandefendermodestructureattack-png{
+ clip-path: xywh(0 79.8234552332913% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -29.886506935687265%);
+}
+
+.btn-upgrade-terran-optimizedlogistics-png{
+ clip-path: xywh(0 79.94955863808323% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -30.0126103404792%);
+}
+
+.btn-upgrade-terran-reapercombatdrugs-png{
+ clip-path: xywh(0 80.07566204287515% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -30.13871374527112%);
+}
+
+.btn-upgrade-terran-replenishablemagazinelvl2-png{
+ clip-path: xywh(0 80.20176544766709% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -30.264817150063053%);
+}
+
+.btn-upgrade-terran-researchdrillingclaws-png{
+ clip-path: xywh(0 80.32786885245902% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -30.390920554854986%);
+}
+
+.btn-upgrade-terran-shipplatinglevel1-png{
+ clip-path: xywh(0 80.45397225725094% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -30.517023959646906%);
+}
+
+.btn-upgrade-terran-shipplatinglevel2-png{
+ clip-path: xywh(0 80.58007566204287% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -30.64312736443884%);
+}
+
+.btn-upgrade-terran-shipplatinglevel3-png{
+ clip-path: xywh(0 80.7061790668348% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -30.769230769230774%);
+}
+
+.btn-upgrade-terran-shipplatinglevel4-png{
+ clip-path: xywh(0 80.83228247162674% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -30.895334174022693%);
+}
+
+.btn-upgrade-terran-shipplatinglevel5-png{
+ clip-path: xywh(0 80.95838587641866% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -31.021437578814627%);
+}
+
+.btn-upgrade-terran-shipweaponslevel1-png{
+ clip-path: xywh(0 81.0844892812106% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -31.14754098360656%);
+}
+
+.btn-upgrade-terran-shipweaponslevel2-png{
+ clip-path: xywh(0 81.21059268600253% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -31.27364438839848%);
+}
+
+.btn-upgrade-terran-shipweaponslevel3-png{
+ clip-path: xywh(0 81.33669609079445% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -31.399747793190414%);
+}
+
+.btn-upgrade-terran-shipweaponslevel4-png{
+ clip-path: xywh(0 81.46279949558638% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -31.525851197982348%);
+}
+
+.btn-upgrade-terran-shipweaponslevel5-png{
+ clip-path: xywh(0 81.58890290037832% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -31.651954602774282%);
+}
+
+.btn-upgrade-terran-superstimppack-png{
+ clip-path: xywh(0 81.71500630517023% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -31.7780580075662%);
+}
+
+.btn-upgrade-terran-transformationservos-png{
+ clip-path: xywh(0 81.84110970996217% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -31.904161412358135%);
+}
+
+.btn-upgrade-terran-trilithium-power-cell-png{
+ clip-path: xywh(0 81.9672131147541% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -32.03026481715007%);
+}
+
+.btn-upgrade-terran-tungsten-spikes-png{
+ clip-path: xywh(0 82.09331651954602% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -32.15636822194199%);
+}
+
+.btn-upgrade-terran-twin-linkedflamethrower-color-png{
+ clip-path: xywh(0 82.21941992433796% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -32.28247162673392%);
+}
+
+.btn-upgrade-terran-vehicleplatinglevel1-png{
+ clip-path: xywh(0 82.34552332912989% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -32.40857503152586%);
+}
+
+.btn-upgrade-terran-vehicleplatinglevel2-png{
+ clip-path: xywh(0 82.47162673392181% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -32.534678436317776%);
+}
+
+.btn-upgrade-terran-vehicleplatinglevel3-png{
+ clip-path: xywh(0 82.59773013871374% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -32.66078184110971%);
+}
+
+.btn-upgrade-terran-vehicleplatinglevel4-png{
+ clip-path: xywh(0 82.72383354350568% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -32.786885245901644%);
+}
+
+.btn-upgrade-terran-vehicleplatinglevel5-png{
+ clip-path: xywh(0 82.84993694829761% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -32.91298865069356%);
+}
+
+.btn-upgrade-terran-vehicleweaponslevel1-png{
+ clip-path: xywh(0 82.97604035308953% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -33.0390920554855%);
+}
+
+.btn-upgrade-terran-vehicleweaponslevel2-png{
+ clip-path: xywh(0 83.10214375788146% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -33.16519546027743%);
+}
+
+.btn-upgrade-terran-vehicleweaponslevel3-png{
+ clip-path: xywh(0 83.2282471626734% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -33.29129886506935%);
+}
+
+.btn-upgrade-terran-vehicleweaponslevel4-png{
+ clip-path: xywh(0 83.35435056746532% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -33.417402269861284%);
+}
+
+.btn-upgrade-terran-vehicleweaponslevel5-png{
+ clip-path: xywh(0 83.48045397225725% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -33.54350567465322%);
+}
+
+.btn-upgrade-vorazun-corsairpermanentlycloaked-png{
+ clip-path: xywh(0 83.60655737704919% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -33.66960907944514%);
+}
+
+.btn-upgrade-vorazun-oraclepermanentlycloaked-png{
+ clip-path: xywh(0 83.7326607818411% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -33.79571248423707%);
+}
+
+.btn-upgrade-zagara-aberrationarmorcover-png{
+ clip-path: xywh(0 83.85876418663304% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -33.921815889029006%);
+}
+
+.btn-upgrade-zagara-increasebilelauncherrange-png{
+ clip-path: xywh(0 83.98486759142497% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -34.04791929382094%);
+}
+
+.btn-upgrade-zagara-scourgesplashdamage-png{
+ clip-path: xywh(0 84.11097099621689% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -34.17402269861286%);
+}
+
+.btn-upgrade-zerg-abathur-abduct-png{
+ clip-path: xywh(0 84.23707440100883% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -34.30012610340479%);
+}
+
+.btn-upgrade-zerg-abathur-biomass-png{
+ clip-path: xywh(0 84.36317780580076% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -34.42622950819673%);
+}
+
+.btn-upgrade-zerg-abathur-biomechanicaltransfusion-png{
+ clip-path: xywh(0 84.48928121059268% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -34.552332912988646%);
+}
+
+.btn-upgrade-zerg-abathur-castrange-png{
+ clip-path: xywh(0 84.61538461538461% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -34.67843631778058%);
+}
+
+.btn-upgrade-zerg-abathur-devourer-corrosivespray-png{
+ clip-path: xywh(0 84.74148802017655% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -34.804539722572514%);
+}
+
+.btn-upgrade-zerg-abathur-improvedmend-png{
+ clip-path: xywh(0 84.86759142496848% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -34.930643127364434%);
+}
+
+.btn-upgrade-zerg-abathur-incubationchamber-png{
+ clip-path: xywh(0 84.9936948297604% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -35.05674653215637%);
+}
+
+.btn-upgrade-zerg-abathur-prolongeddispersion-png{
+ clip-path: xywh(0 85.11979823455233% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -35.1828499369483%);
+}
+
+.btn-upgrade-zerg-adaptivecarapace-png{
+ clip-path: xywh(0 85.24590163934427% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -35.30895334174022%);
+}
+
+.btn-upgrade-zerg-adaptivetalons-png{
+ clip-path: xywh(0 85.37200504413619% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -35.435056746532155%);
+}
+
+.btn-upgrade-zerg-adrenaloverload-png{
+ clip-path: xywh(0 85.49810844892812% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -35.56116015132409%);
+}
+
+.btn-upgrade-zerg-airattacks-level1-png{
+ clip-path: xywh(0 85.62421185372006% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -35.68726355611601%);
+}
+
+.btn-upgrade-zerg-airattacks-level2-png{
+ clip-path: xywh(0 85.75031525851198% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -35.81336696090794%);
+}
+
+.btn-upgrade-zerg-airattacks-level3-png{
+ clip-path: xywh(0 85.87641866330391% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -35.939470365699876%);
+}
+
+.btn-upgrade-zerg-airattacks-level4-png{
+ clip-path: xywh(0 86.00252206809584% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -36.06557377049181%);
+}
+
+.btn-upgrade-zerg-airattacks-level5-png{
+ clip-path: xywh(0 86.12862547288776% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -36.19167717528373%);
+}
+
+.btn-upgrade-zerg-anabolicsynthesis-png{
+ clip-path: xywh(0 86.2547288776797% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -36.31778058007566%);
+}
+
+.btn-upgrade-zerg-ancillaryarmor-png{
+ clip-path: xywh(0 86.38083228247163% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -36.4438839848676%);
+}
+
+.btn-upgrade-zerg-buildingarmor-png{
+ clip-path: xywh(0 86.50693568726355% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -36.56998738965952%);
+}
+
+.btn-upgrade-zerg-burrowcharge-png{
+ clip-path: xywh(0 86.63303909205548% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -36.69609079445145%);
+}
+
+.btn-upgrade-zerg-burrowmove-png{
+ clip-path: xywh(0 86.75914249684742% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -36.822194199243384%);
+}
+
+.btn-upgrade-zerg-celldivisionon-png{
+ clip-path: xywh(0 86.88524590163935% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -36.948297604035304%);
+}
+
+.btn-upgrade-zerg-centrifugalhooks-png{
+ clip-path: xywh(0 87.01134930643127% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -37.07440100882724%);
+}
+
+.btn-upgrade-zerg-chitinousplating-png{
+ clip-path: xywh(0 87.1374527112232% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -37.20050441361917%);
+}
+
+.btn-upgrade-zerg-concentrated-spew-png{
+ clip-path: xywh(0 87.26355611601514% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -37.32660781841109%);
+}
+
+.btn-upgrade-zerg-corrosiveacid-png{
+ clip-path: xywh(0 87.38965952080706% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -37.452711223203025%);
+}
+
+.btn-upgrade-zerg-dehaka-tenderize-png{
+ clip-path: xywh(0 87.51576292559899% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -37.57881462799496%);
+}
+
+.btn-upgrade-zerg-demolition-png{
+ clip-path: xywh(0 87.64186633039093% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -37.70491803278688%);
+}
+
+.btn-upgrade-zerg-enduringcorruption-png{
+ clip-path: xywh(0 87.76796973518285% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -37.83102143757881%);
+}
+
+.btn-upgrade-zerg-evolveincreasedlocustlifetime-png{
+ clip-path: xywh(0 87.89407313997478% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -37.957124842370746%);
+}
+
+.btn-upgrade-zerg-evolvemuscularaugments-png{
+ clip-path: xywh(0 88.02017654476671% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -38.08322824716268%);
+}
+
+.btn-upgrade-zerg-explosiveglaive-png{
+ clip-path: xywh(0 88.14627994955863% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -38.2093316519546%);
+}
+
+.btn-upgrade-zerg-flyercarapace-level1-png{
+ clip-path: xywh(0 88.27238335435057% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -38.33543505674653%);
+}
+
+.btn-upgrade-zerg-flyercarapace-level2-png{
+ clip-path: xywh(0 88.3984867591425% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -38.46153846153847%);
+}
+
+.btn-upgrade-zerg-flyercarapace-level3-png{
+ clip-path: xywh(0 88.52459016393442% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -38.58764186633039%);
+}
+
+.btn-upgrade-zerg-flyercarapace-level4-png{
+ clip-path: xywh(0 88.65069356872635% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -38.71374527112232%);
+}
+
+.btn-upgrade-zerg-flyercarapace-level5-png{
+ clip-path: xywh(0 88.77679697351829% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -38.839848675914254%);
+}
+
+.btn-upgrade-zerg-frenzy-png{
+ clip-path: xywh(0 88.90290037831022% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -38.965952080706174%);
+}
+
+.btn-upgrade-zerg-glialreconstitution-png{
+ clip-path: xywh(0 89.02900378310214% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -39.09205548549811%);
+}
+
+.btn-upgrade-zerg-groovedspines-png{
+ clip-path: xywh(0 89.15510718789407% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -39.21815889029004%);
+}
+
+.btn-upgrade-zerg-groundcarapace-level1-png{
+ clip-path: xywh(0 89.28121059268601% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -39.34426229508196%);
+}
+
+.btn-upgrade-zerg-groundcarapace-level2-png{
+ clip-path: xywh(0 89.40731399747793% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -39.470365699873895%);
+}
+
+.btn-upgrade-zerg-groundcarapace-level3-png{
+ clip-path: xywh(0 89.53341740226986% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -39.59646910466583%);
+}
+
+.btn-upgrade-zerg-groundcarapace-level4-png{
+ clip-path: xywh(0 89.6595208070618% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -39.72257250945775%);
+}
+
+.btn-upgrade-zerg-groundcarapace-level5-png{
+ clip-path: xywh(0 89.78562421185372% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -39.84867591424968%);
+}
+
+.btn-upgrade-zerg-hardenedcarapace-png{
+ clip-path: xywh(0 89.91172761664565% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -39.974779319041616%);
+}
+
+.btn-upgrade-zerg-hotsgroovedspines-png{
+ clip-path: xywh(0 90.03783102143758% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -40.10088272383355%);
+}
+
+.btn-upgrade-zerg-hotsmetabolicboost-png{
+ clip-path: xywh(0 90.1639344262295% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -40.22698612862547%);
+}
+
+.btn-upgrade-zerg-hotstunnelingclaws-png{
+ clip-path: xywh(0 90.29003783102144% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -40.3530895334174%);
+}
+
+.btn-upgrade-zerg-hydriaticacid-png{
+ clip-path: xywh(0 90.41614123581337% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -40.47919293820934%);
+}
+
+.btn-upgrade-zerg-meleeattacks-level1-png{
+ clip-path: xywh(0 90.54224464060529% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -40.60529634300126%);
+}
+
+.btn-upgrade-zerg-meleeattacks-level2-png{
+ clip-path: xywh(0 90.66834804539722% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -40.73139974779319%);
+}
+
+.btn-upgrade-zerg-meleeattacks-level3-png{
+ clip-path: xywh(0 90.79445145018916% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -40.857503152585124%);
+}
+
+.btn-upgrade-zerg-meleeattacks-level4-png{
+ clip-path: xywh(0 90.92055485498109% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -40.983606557377044%);
+}
+
+.btn-upgrade-zerg-meleeattacks-level5-png{
+ clip-path: xywh(0 91.04665825977301% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -41.10970996216898%);
+}
+
+.btn-upgrade-zerg-missileattacks-level1-png{
+ clip-path: xywh(0 91.17276166456494% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -41.23581336696091%);
+}
+
+.btn-upgrade-zerg-missileattacks-level2-png{
+ clip-path: xywh(0 91.29886506935688% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -41.36191677175283%);
+}
+
+.btn-upgrade-zerg-missileattacks-level3-png{
+ clip-path: xywh(0 91.4249684741488% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -41.488020176544765%);
+}
+
+.btn-upgrade-zerg-missileattacks-level4-png{
+ clip-path: xywh(0 91.55107187894073% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -41.6141235813367%);
+}
+
+.btn-upgrade-zerg-missileattacks-level5-png{
+ clip-path: xywh(0 91.67717528373267% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -41.74022698612862%);
+}
+
+.btn-upgrade-zerg-monarchblades-png{
+ clip-path: xywh(0 91.80327868852459% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -41.86633039092055%);
+}
+
+.btn-upgrade-zerg-organiccarapace-png{
+ clip-path: xywh(0 91.92938209331652% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -41.992433795712486%);
+}
+
+.btn-upgrade-zerg-pneumatizedcarapace-png{
+ clip-path: xywh(0 92.05548549810845% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -42.11853720050442%);
+}
+
+.btn-upgrade-zerg-pressurizedglands-png{
+ clip-path: xywh(0 92.18158890290037% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -42.24464060529634%);
+}
+
+.btn-upgrade-zerg-rapidincubation-png{
+ clip-path: xywh(0 92.3076923076923% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -42.37074401008827%);
+}
+
+.btn-upgrade-zerg-rapidregeneration-png{
+ clip-path: xywh(0 92.43379571248424% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -42.49684741488021%);
+}
+
+.btn-upgrade-zerg-regenerativebile-png{
+ clip-path: xywh(0 92.55989911727616% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -42.62295081967213%);
+}
+
+.btn-upgrade-zerg-rupture-png{
+ clip-path: xywh(0 92.6860025220681% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -42.74905422446406%);
+}
+
+.btn-upgrade-zerg-stukov-bansheeburrowregeneration-png{
+ clip-path: xywh(0 92.81210592686003% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -42.875157629255995%);
+}
+
+.btn-upgrade-zerg-stukov-bansheemorelife-png{
+ clip-path: xywh(0 92.93820933165196% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -43.001261034047914%);
+}
+
+.btn-upgrade-zerg-stukov-bunkerformliferegenupgraded-png{
+ clip-path: xywh(0 93.06431273644388% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -43.12736443883985%);
+}
+
+.btn-upgrade-zerg-stukov-bunkerresearchbundle_05-png{
+ clip-path: xywh(0 93.19041614123581% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -43.25346784363178%);
+}
+
+.btn-upgrade-zerg-stukov-bunkerupgradeii_14-png{
+ clip-path: xywh(0 93.31651954602775% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -43.3795712484237%);
+}
+
+.btn-upgrade-zerg-stukov-diamondbacksnailtrail-png{
+ clip-path: xywh(0 93.44262295081967% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -43.505674653215635%);
+}
+
+.btn-upgrade-zerg-stukov-infestedbunkermorelife-png{
+ clip-path: xywh(0 93.5687263556116% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -43.63177805800757%);
+}
+
+.btn-upgrade-zerg-stukov-infestedliberatoraoe-png{
+ clip-path: xywh(0 93.69482976040354% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -43.75788146279949%);
+}
+
+.btn-upgrade-zerg-stukov-infestedliberatorswarmcloud-png{
+ clip-path: xywh(0 93.82093316519546% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -43.88398486759142%);
+}
+
+.btn-upgrade-zerg-stukov-infestedmarinerangeupgrade-png{
+ clip-path: xywh(0 93.94703656998739% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -44.010088272383356%);
+}
+
+.btn-upgrade-zerg-stukov-infestedspawnbroodling-png{
+ clip-path: xywh(0 94.07313997477932% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -44.13619167717529%);
+}
+
+.btn-upgrade-zerg-stukov-queenenergyregen-png{
+ clip-path: xywh(0 94.19924337957124% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -44.26229508196721%);
+}
+
+.btn-upgrade-zerg-stukov-researchqueenfungalgrowth-png{
+ clip-path: xywh(0 94.32534678436318% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -44.388398486759144%);
+}
+
+.btn-upgrade-zerg-stukov-siegetankammoregen-png{
+ clip-path: xywh(0 94.45145018915511% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -44.51450189155108%);
+}
+
+.btn-upgrade-zerg-stukov-siegetankbonusdamage-png{
+ clip-path: xywh(0 94.57755359394703% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -44.640605296343%);
+}
+
+.btn-upgrade-zerg-swarmfrenzy-png{
+ clip-path: xywh(0 94.70365699873896% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -44.76670870113493%);
+}
+
+.btn-upgrade-zerg-tissueassimilation-png{
+ clip-path: xywh(0 94.8297604035309% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -44.892812105926865%);
+}
+
+.btn-upgrade-zerg-tunnelingjaws-png{
+ clip-path: xywh(0 94.95586380832283% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -45.018915510718784%);
+}
+
+.btn-upgrade-zerg-ventralsacs-png{
+ clip-path: xywh(0 95.08196721311475% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -45.14501891551072%);
+}
+
+.btn-upgrade-zerg-viciousglaive-png{
+ clip-path: xywh(0 95.20807061790669% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -45.27112232030265%);
+}
+
+.btn-upgrade-zergling-armorshredding-png{
+ clip-path: xywh(0 95.33417402269862% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -45.39722572509457%);
+}
+
+.btn-veil-of-the-judicator-png{
+ clip-path: xywh(0 95.46027742749054% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -45.523329129886505%);
+}
+
+.btn-warp-refraction-png{
+ clip-path: xywh(0 95.58638083228247% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -45.64943253467844%);
+}
+
+.evolution_coop-png{
+ clip-path: xywh(0 95.7124842370744% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -45.77553593947036%);
+}
+
+.icon-bargain-bin-prices-png{
+ clip-path: xywh(0 95.83858764186633% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -45.90163934426229%);
+}
+
+.icon-gas-terran-nobg-png{
+ clip-path: xywh(0 95.96469104665826% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -46.02774274905423%);
+}
+
+.icon-health-nobg-png{
+ clip-path: xywh(0 96.0907944514502% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -46.15384615384616%);
+}
+
+.icon-mineral-nobg-png{
+ clip-path: xywh(0 96.21689785624211% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -46.27994955863808%);
+}
+
+.icon-shields-png{
+ clip-path: xywh(0 96.34300126103405% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -46.406052963430014%);
+}
+
+.icon-supply-protoss_nobg-png{
+ clip-path: xywh(0 96.46910466582598% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -46.53215636822195%);
+}
+
+.icon-supply-terran_nobg-png{
+ clip-path: xywh(0 96.5952080706179% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -46.65825977301387%);
+}
+
+.icon-supply-zerg_nobg-png{
+ clip-path: xywh(0 96.72131147540983% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -46.7843631778058%);
+}
+
+.icon-time-protoss-png{
+ clip-path: xywh(0 96.84741488020177% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -46.910466582597735%);
+}
+
+.potentbile_coop-png{
+ clip-path: xywh(0 96.9735182849937% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -47.036569987389655%);
+}
+
+.predatorcharge-png{
+ clip-path: xywh(0 97.09962168978562% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -47.16267339218159%);
+}
+
+.predatorvespene-png{
+ clip-path: xywh(0 97.22572509457756% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -47.28877679697352%);
+}
+
+.talent-artanis-level03-warpgatecharges-png{
+ clip-path: xywh(0 97.35182849936949% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -47.41488020176544%);
+}
+
+.talent-artanis-level14-startingmaxsupply-png{
+ clip-path: xywh(0 97.47793190416141% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -47.540983606557376%);
+}
+
+.talent-raynor-level03-firebatmedicrange-png{
+ clip-path: xywh(0 97.60403530895334% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -47.66708701134931%);
+}
+
+.talent-raynor-level08-orbitaldroppods-png{
+ clip-path: xywh(0 97.73013871374528% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -47.79319041614123%);
+}
+
+.talent-raynor-level14-infantryattackspeed-png{
+ clip-path: xywh(0 97.8562421185372% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -47.91929382093316%);
+}
+
+.talent-swann-level12-immortalityprotocol-png{
+ clip-path: xywh(0 97.98234552332913% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -48.0453972257251%);
+}
+
+.talent-swann-level14-vehiclehealthincrease-png{
+ clip-path: xywh(0 98.10844892812106% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -48.17150063051703%);
+}
+
+.talent-tychus-level02-additionaloutlaw-png{
+ clip-path: xywh(0 98.23455233291298% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -48.29760403530895%);
+}
+
+.talent-tychus-level07-firstdiscount-png{
+ clip-path: xywh(0 98.36065573770492% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -48.423707440100884%);
+}
+
+.talent-vorazun-level01-shadowstalk-png{
+ clip-path: xywh(0 98.48675914249685% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -48.54981084489282%);
+}
+
+.talent-vorazun-level05-unlockdarkarchon-png{
+ clip-path: xywh(0 98.61286254728877% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -48.67591424968474%);
+}
+
+.talent-zagara-level12-unlockswarmling-png{
+ clip-path: xywh(0 98.7389659520807% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -48.80201765447667%);
+}
+
+.talent-zagara-level14-unlocksplitterling-png{
+ clip-path: xywh(0 98.86506935687264% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -48.928121059268605%);
+}
+
+.tip_terrazinefog-png{
+ clip-path: xywh(0 98.99117276166457% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -49.054224464060525%);
+}
+
+.ui_aicommand_build_open_aggressivepush-png{
+ clip-path: xywh(0 99.11727616645649% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -49.18032786885246%);
+}
+
+.ui_btn_generic_exclemation_red-png{
+ clip-path: xywh(0 99.24337957124843% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -49.30643127364439%);
+}
+
+.ui_glues_help_armyicon_protoss-png{
+ clip-path: xywh(0 99.36948297604036% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -49.43253467843631%);
+}
+
+.ui_glues_help_armyicon_terran-png{
+ clip-path: xywh(0 99.49558638083228% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -49.558638083228246%);
+}
+
+.ui_glues_help_armyicon_zerg-png{
+ clip-path: xywh(0 99.62168978562421% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -49.68474148802018%);
+}
+
+.ui_tipicon_evolution_hydralisk-waves-png{
+ clip-path: xywh(0 99.74779319041615% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -49.8108448928121%);
+}
+
+.vultureautolaunchers-png{
+ clip-path: xywh(0 99.87389659520807% 100% 0.12610340479192939%);
+ transform: scale(1, 793) translate(0, -49.93694829760403%);
+}
+
diff --git a/WebHostLib/static/styles/sc2wolTracker.css b/WebHostLib/static/styles/sc2wolTracker.css
deleted file mode 100644
index b68668ecf60e..000000000000
--- a/WebHostLib/static/styles/sc2wolTracker.css
+++ /dev/null
@@ -1,110 +0,0 @@
-#player-tracker-wrapper{
- margin: 0;
-}
-
-#inventory-table{
- border-top: 2px solid #000000;
- border-left: 2px solid #000000;
- border-right: 2px solid #000000;
- border-top-left-radius: 4px;
- border-top-right-radius: 4px;
- padding: 3px 3px 10px;
- width: 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;
-}
diff --git a/WebHostLib/static/styles/supportedGames.css b/WebHostLib/static/styles/supportedGames.css
index f86ab581ca47..ab12f320716b 100644
--- a/WebHostLib/static/styles/supportedGames.css
+++ b/WebHostLib/static/styles/supportedGames.css
@@ -8,14 +8,15 @@
cursor: unset;
}
-#games h1{
+#games h1, #games details summary.h1{
font-size: 60px;
cursor: unset;
}
-#games h2{
+#games h2, #games details summary.h2{
color: #93dcff;
margin-bottom: 2px;
+ text-transform: none;
}
#games a{
@@ -31,3 +32,13 @@
line-height: 25px;
margin-bottom: 7px;
}
+
+#games .page-controls{
+ display: flex;
+ flex-direction: row;
+ margin-top: 0.25rem;
+}
+
+#games .page-controls button{
+ margin-left: 0.5rem;
+}
diff --git a/WebHostLib/static/styles/themes/ocean-island.css b/WebHostLib/static/styles/themes/ocean-island.css
index 2b45fb9d167c..3216e5e3e2df 100644
--- a/WebHostLib/static/styles/themes/ocean-island.css
+++ b/WebHostLib/static/styles/themes/ocean-island.css
@@ -72,3 +72,13 @@ code{
padding-right: 0.25rem;
color: #000000;
}
+
+code.grassy {
+ background-color: #b5e9a4;
+ border: 1px solid #2a6c2f;
+ white-space: preserve;
+ text-align: left;
+ display: block;
+ font-size: 14px;
+ line-height: 20px;
+}
diff --git a/WebHostLib/static/styles/timespinnerTracker.css b/WebHostLib/static/styles/timespinnerTracker.css
index 007c6a19ba9a..640b5846840b 100644
--- a/WebHostLib/static/styles/timespinnerTracker.css
+++ b/WebHostLib/static/styles/timespinnerTracker.css
@@ -75,6 +75,27 @@
#inventory-table img.acquired.green{ /*32CD32*/
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
}
+#inventory-table img.acquired.hotpink{ /*FF69B4*/
+ filter: sepia(100%) hue-rotate(300deg) saturate(10);
+}
+#inventory-table img.acquired.lightsalmon{ /*FFA07A*/
+ filter: sepia(100%) hue-rotate(347deg) saturate(10);
+}
+#inventory-table img.acquired.crimson{ /*DB143B*/
+ filter: sepia(100%) hue-rotate(318deg) saturate(10) brightness(0.86);
+}
+
+#inventory-table span{
+ color: #B4B4A0;
+ font-size: 40px;
+ max-width: 40px;
+ max-height: 40px;
+ filter: grayscale(100%) contrast(75%) brightness(30%);
+}
+
+#inventory-table span.acquired{
+ filter: none;
+}
#inventory-table div.image-stack{
display: grid;
diff --git a/WebHostLib/static/styles/tooltip.css b/WebHostLib/static/styles/tooltip.css
index 7cd8463f64a4..dc9026ce6c3d 100644
--- a/WebHostLib/static/styles/tooltip.css
+++ b/WebHostLib/static/styles/tooltip.css
@@ -12,12 +12,12 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
*/
/* Base styles for the element that has a tooltip */
-[data-tooltip], .tooltip {
+[data-tooltip], .tooltip-container {
position: relative;
}
/* Base styles for the entire tooltip */
-[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after {
+[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
position: absolute;
visibility: hidden;
opacity: 0;
@@ -39,13 +39,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
pointer-events: none;
}
-[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after{
+[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before,
+.tooltip-container:hover .tooltip {
visibility: visible;
opacity: 1;
+ word-break: break-word;
}
/** Directional arrow styles */
-.tooltip:before, [data-tooltip]:before {
+[data-tooltip]:before, .tooltip-container:before {
z-index: 10000;
border: 6px solid transparent;
background: transparent;
@@ -53,7 +55,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
}
/** Content styles */
-.tooltip:after, [data-tooltip]:after {
+[data-tooltip]:after, .tooltip {
width: 260px;
z-index: 10000;
padding: 8px;
@@ -62,24 +64,26 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
background-color: hsla(0, 0%, 20%, 0.9);
color: #fff;
content: attr(data-tooltip);
- white-space: pre-wrap;
font-size: 14px;
line-height: 1.2;
}
-[data-tooltip]:before, [data-tooltip]:after{
+[data-tooltip]:after {
+ white-space: pre-wrap;
+}
+
+[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
visibility: hidden;
opacity: 0;
pointer-events: none;
}
-[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after,
-.tooltip-top:before, .tooltip-top:after {
+[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
bottom: 100%;
left: 50%;
}
-[data-tooltip]:before, .tooltip:before, .tooltip-top:before {
+[data-tooltip]:before, .tooltip-container:before {
margin-left: -6px;
margin-bottom: -12px;
border-top-color: #000;
@@ -87,19 +91,19 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
}
/** Horizontally align tooltips on the top and bottom */
-[data-tooltip]:after, .tooltip:after, .tooltip-top:after {
+[data-tooltip]:after, .tooltip {
margin-left: -80px;
}
-[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after,
-.tooltip-top:hover:before, .tooltip-top:hover:after {
+[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before,
+.tooltip-container:hover .tooltip {
-webkit-transform: translateY(-12px);
-moz-transform: translateY(-12px);
transform: translateY(-12px);
}
/** Tooltips on the left */
-.tooltip-left:before, .tooltip-left:after {
+.tooltip-left:before, [data-tooltip].tooltip-left:after, .tooltip-left .tooltip {
right: 100%;
bottom: 50%;
left: auto;
@@ -114,14 +118,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
border-left-color: hsla(0, 0%, 20%, 0.9);
}
-.tooltip-left:hover:before, .tooltip-left:hover:after {
+.tooltip-left:hover:before, [data-tooltip].tooltip-left:hover:after, .tooltip-left:hover .tooltip {
-webkit-transform: translateX(-12px);
-moz-transform: translateX(-12px);
transform: translateX(-12px);
}
/** Tooltips on the bottom */
-.tooltip-bottom:before, .tooltip-bottom:after {
+.tooltip-bottom:before, [data-tooltip].tooltip-bottom:after, .tooltip-bottom .tooltip {
top: 100%;
bottom: auto;
left: 50%;
@@ -135,14 +139,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
border-bottom-color: hsla(0, 0%, 20%, 0.9);
}
-.tooltip-bottom:hover:before, .tooltip-bottom:hover:after {
+.tooltip-bottom:hover:before, [data-tooltip].tooltip-bottom:hover:after,
+.tooltip-bottom:hover .tooltip {
-webkit-transform: translateY(12px);
-moz-transform: translateY(12px);
transform: translateY(12px);
}
/** Tooltips on the right */
-.tooltip-right:before, .tooltip-right:after {
+.tooltip-right:before, [data-tooltip].tooltip-right:after, .tooltip-right .tooltip {
bottom: 50%;
left: 100%;
}
@@ -155,7 +160,8 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
border-right-color: hsla(0, 0%, 20%, 0.9);
}
-.tooltip-right:hover:before, .tooltip-right:hover:after {
+.tooltip-right:hover:before, [data-tooltip].tooltip-right:hover:after,
+.tooltip-right:hover .tooltip {
-webkit-transform: translateX(12px);
-moz-transform: translateX(12px);
transform: translateX(12px);
@@ -167,7 +173,16 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
}
/** Center content vertically for tooltips ont he left and right */
-.tooltip-left:after, .tooltip-right:after {
+[data-tooltip].tooltip-left:after, [data-tooltip].tooltip-right:after,
+.tooltip-left .tooltip, .tooltip-right .tooltip {
margin-left: 0;
margin-bottom: -16px;
}
+
+.tooltip ul, .tooltip ol {
+ padding-left: 1rem;
+}
+
+.tooltip :last-child {
+ margin-bottom: 0;
+}
diff --git a/WebHostLib/static/styles/tracker.css b/WebHostLib/static/styles/tracker.css
index 0e00553c72c8..8fcb0c923012 100644
--- a/WebHostLib/static/styles/tracker.css
+++ b/WebHostLib/static/styles/tracker.css
@@ -7,81 +7,119 @@
width: calc(100% - 1rem);
}
-#tracker-wrapper a{
+#tracker-wrapper a {
color: #234ae4;
text-decoration: none;
cursor: pointer;
}
-.table-wrapper{
- overflow-y: auto;
- overflow-x: auto;
- margin-bottom: 1rem;
-}
-
-#tracker-header-bar{
+#tracker-header-bar {
display: flex;
flex-direction: row;
justify-content: flex-start;
+ align-content: center;
line-height: 20px;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
}
-#tracker-header-bar .info{
+#tracker-header-bar .info {
color: #ffffff;
+ padding: 2px;
+ flex-grow: 1;
+ align-self: center;
+ text-align: justify;
+}
+
+#tracker-navigation {
+ display: flex;
+ flex-wrap: wrap;
+ margin: 0 0.5rem 0.5rem 0.5rem;
+ user-select: none;
+ height: 2rem;
+}
+
+.tracker-navigation-bar {
+ display: flex;
+ background-color: #b0a77d;
+ border-radius: 4px;
+}
+
+.tracker-navigation-button {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin: 4px;
+ padding-left: 12px;
+ padding-right: 12px;
+ border-radius: 4px;
+ text-align: center;
+ font-size: 14px;
+ color: black !important;
+ font-weight: lighter;
+}
+
+.tracker-navigation-button:hover {
+ background-color: #e2eabb !important;
+}
+
+.tracker-navigation-button.selected {
+ background-color: rgb(220, 226, 189);
}
-#search{
+.table-wrapper {
+ overflow-y: auto;
+ overflow-x: auto;
+ margin-bottom: 1rem;
+ resize: vertical;
+}
+
+#search {
border: 1px solid #000000;
border-radius: 3px;
padding: 3px;
width: 200px;
- margin-bottom: 0.5rem;
- margin-right: 1rem;
-}
-
-#multi-stream-link{
- margin-right: 1rem;
}
-div.dataTables_wrapper.no-footer .dataTables_scrollBody{
+div.dataTables_wrapper.no-footer .dataTables_scrollBody {
border: none;
}
-table.dataTable{
+table.dataTable {
color: #000000;
}
-table.dataTable thead{
+table.dataTable thead {
font-family: LexendDeca-Regular, sans-serif;
}
-table.dataTable tbody{
+table.dataTable tbody, table.dataTable tfoot {
background-color: #dce2bd;
font-family: LexendDeca-Light, sans-serif;
}
-table.dataTable tbody tr:hover{
+table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover {
background-color: #e2eabb;
}
-table.dataTable tbody td{
+table.dataTable tbody td, table.dataTable tfoot td {
padding: 4px 6px;
}
-table.dataTable, table.dataTable.no-footer{
+table.dataTable, table.dataTable.no-footer {
border-left: 1px solid #bba967;
width: calc(100% - 2px) !important;
font-size: 1rem;
}
-table.dataTable thead th{
+table.dataTable thead th {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
top: 0;
}
-table.dataTable thead th.upper-row{
+table.dataTable thead th.upper-row {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
@@ -89,7 +127,7 @@ table.dataTable thead th.upper-row{
top: 0;
}
-table.dataTable thead th.lower-row{
+table.dataTable thead th.lower-row {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
@@ -97,55 +135,32 @@ table.dataTable thead th.lower-row{
top: 46px;
}
-table.dataTable tbody td{
+table.dataTable tbody td, table.dataTable tfoot td {
border: 1px solid #bba967;
}
-div.dataTables_scrollBody{
+table.dataTable tfoot td {
+ font-weight: bold;
+}
+
+div.dataTables_scrollBody {
background-color: inherit !important;
}
-table.dataTable .center-column{
+table.dataTable .center-column {
text-align: center;
}
-img.alttp-sprite {
+img.icon-sprite {
height: auto;
max-height: 32px;
min-height: 14px;
}
-.item-acquired{
+.item-acquired {
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;
@@ -155,7 +170,7 @@ img.alttp-sprite {
top: 0;
}
- table.dataTable thead th.lower-row{
+ table.dataTable thead th.lower-row {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
@@ -163,11 +178,11 @@ img.alttp-sprite {
top: 37px;
}
- table.dataTable, table.dataTable.no-footer{
+ table.dataTable, table.dataTable.no-footer {
font-size: 0.8rem;
}
- img.alttp-sprite {
+ img.icon-sprite {
height: auto;
max-height: 24px;
min-height: 10px;
@@ -183,7 +198,7 @@ img.alttp-sprite {
top: 0;
}
- table.dataTable thead th.lower-row{
+ table.dataTable thead th.lower-row {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
@@ -191,11 +206,11 @@ img.alttp-sprite {
top: 32px;
}
- table.dataTable, table.dataTable.no-footer{
+ table.dataTable, table.dataTable.no-footer {
font-size: 0.6rem;
}
- img.alttp-sprite {
+ img.icon-sprite {
height: auto;
max-height: 20px;
min-height: 10px;
diff --git a/WebHostLib/static/styles/tracker__ALinkToThePast.css b/WebHostLib/static/styles/tracker__ALinkToThePast.css
new file mode 100644
index 000000000000..db5dfcbdfed7
--- /dev/null
+++ b/WebHostLib/static/styles/tracker__ALinkToThePast.css
@@ -0,0 +1,142 @@
+@import url('https://fonts.googleapis.com/css2?family=Lexend+Deca:wght@100..900&display=swap');
+
+.tracker-container {
+ width: 440px;
+ box-sizing: border-box;
+ font-family: "Lexend Deca", Arial, Helvetica, sans-serif;
+ border: 2px solid black;
+ border-radius: 4px;
+ resize: both;
+
+ background-color: #42b149;
+ color: white;
+}
+
+.hidden {
+ visibility: hidden;
+}
+
+/** Inventory Grid ****************************************************************************************************/
+.inventory-grid {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ padding: 1rem;
+ gap: 1rem;
+}
+
+.inventory-grid .item {
+ position: relative;
+ display: flex;
+ justify-content: center;
+ height: 48px;
+}
+
+.inventory-grid .dual-item {
+ display: flex;
+ justify-content: center;
+}
+
+.inventory-grid .missing {
+ /* Missing items will be in full grayscale to signify "uncollected". */
+ filter: grayscale(100%) contrast(75%) brightness(75%);
+}
+
+.inventory-grid .item img,
+.inventory-grid .dual-item img {
+ display: flex;
+ align-items: center;
+ text-align: center;
+ font-size: 0.8rem;
+ text-shadow: 0 1px 2px black;
+ font-weight: bold;
+ image-rendering: crisp-edges;
+ background-size: contain;
+ background-repeat: no-repeat;
+}
+
+.inventory-grid .dual-item img {
+ height: 48px;
+ margin: 0 -4px;
+}
+
+.inventory-grid .dual-item img:first-child {
+ align-self: flex-end;
+}
+
+.inventory-grid .item .quantity {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ text-align: right;
+ font-weight: 600;
+ font-size: 1.75rem;
+ line-height: 1.75rem;
+ text-shadow:
+ -1px -1px 0 #000,
+ 1px -1px 0 #000,
+ -1px 1px 0 #000,
+ 1px 1px 0 #000;
+ user-select: none;
+}
+
+/** Regions List ******************************************************************************************************/
+.regions-list {
+ padding: 1rem;
+}
+
+.regions-list summary {
+ list-style: none;
+ display: flex;
+ gap: 0.5rem;
+ cursor: pointer;
+}
+
+.regions-list summary::before {
+ content: "â¯";
+ width: 1em;
+ flex-shrink: 0;
+}
+
+.regions-list details {
+ font-weight: 300;
+}
+
+.regions-list details[open] > summary::before {
+ content: "â¯";
+}
+
+.regions-list .region {
+ width: 100%;
+ display: grid;
+ grid-template-columns: 20fr 8fr 2fr 2fr;
+ align-items: center;
+ gap: 4px;
+ text-align: center;
+ font-weight: 300;
+ box-sizing: border-box;
+}
+
+.regions-list .region :first-child {
+ text-align: left;
+ font-weight: 500;
+}
+
+.regions-list .region.region-header {
+ margin-left: 24px;
+ width: calc(100% - 24px);
+ padding: 2px;
+}
+
+.regions-list .location-rows {
+ border-top: 1px solid white;
+ display: grid;
+ grid-template-columns: auto 32px;
+ font-weight: 300;
+ padding: 2px 8px;
+ margin-top: 4px;
+ font-size: 0.8rem;
+}
+
+.regions-list .location-rows :nth-child(even) {
+ text-align: right;
+}
diff --git a/WebHostLib/static/styles/waitSeed.css b/WebHostLib/static/styles/waitSeed.css
index 85d281b20dff..0b4e4c328c34 100644
--- a/WebHostLib/static/styles/waitSeed.css
+++ b/WebHostLib/static/styles/waitSeed.css
@@ -13,3 +13,7 @@
min-height: 360px;
text-align: center;
}
+
+h2, h4 {
+ color: #ffffff;
+}
diff --git a/WebHostLib/static/styles/weighted-settings.css b/WebHostLib/static/styles/weighted-settings.css
deleted file mode 100644
index cc5231634e5b..000000000000
--- a/WebHostLib/static/styles/weighted-settings.css
+++ /dev/null
@@ -1,309 +0,0 @@
-html{
- background-image: url('../static/backgrounds/grass.png');
- background-repeat: repeat;
- background-size: 650px 650px;
- scroll-padding-top: 90px;
-}
-
-#weighted-settings{
- max-width: 1000px;
- margin-left: auto;
- margin-right: auto;
- background-color: rgba(0, 0, 0, 0.15);
- border-radius: 8px;
- padding: 1rem;
- color: #eeffeb;
-}
-
-#weighted-settings #games-wrapper{
- width: 100%;
-}
-
-#weighted-settings .setting-wrapper{
- width: 100%;
- margin-bottom: 2rem;
-}
-
-#weighted-settings .setting-wrapper .add-option-div{
- display: flex;
- flex-direction: row;
- justify-content: flex-start;
- margin-bottom: 1rem;
-}
-
-#weighted-settings .setting-wrapper .add-option-div button{
- width: auto;
- height: auto;
- margin: 0 0 0 0.15rem;
- padding: 0 0.25rem;
- border-radius: 4px;
- cursor: default;
-}
-
-#weighted-settings .setting-wrapper .add-option-div button:active{
- margin-bottom: 1px;
-}
-
-#weighted-settings p.setting-description{
- margin: 0 0 1rem;
-}
-
-#weighted-settings p.hint-text{
- margin: 0 0 1rem;
- font-style: italic;
-}
-
-#weighted-settings .jump-link{
- color: #ffef00;
- cursor: pointer;
- text-decoration: underline;
-}
-
-#weighted-settings table{
- width: 100%;
-}
-
-#weighted-settings table th, #weighted-settings table td{
- border: none;
-}
-
-#weighted-settings table td{
- padding: 5px;
-}
-
-#weighted-settings table .td-left{
- font-family: LexendDeca-Regular, sans-serif;
- padding-right: 1rem;
- width: 200px;
-}
-
-#weighted-settings table .td-middle{
- display: flex;
- flex-direction: column;
- justify-content: space-evenly;
- padding-right: 1rem;
-}
-
-#weighted-settings table .td-right{
- width: 4rem;
- text-align: right;
-}
-
-#weighted-settings table .td-delete{
- width: 50px;
- text-align: right;
-}
-
-#weighted-settings table .range-option-delete{
- cursor: pointer;
-}
-
-#weighted-settings .items-wrapper{
- display: flex;
- flex-direction: row;
- justify-content: space-between;
-}
-
-#weighted-settings .items-div h3{
- margin-bottom: 0.5rem;
-}
-
-#weighted-settings .items-wrapper .item-set-wrapper{
- width: 24%;
- font-weight: bold;
-}
-
-#weighted-settings .item-container{
- border: 1px solid #ffffff;
- border-radius: 2px;
- width: 100%;
- height: 300px;
- overflow-y: auto;
- overflow-x: hidden;
- margin-top: 0.125rem;
- font-weight: normal;
-}
-
-#weighted-settings .item-container .item-div{
- padding: 0.125rem 0.5rem;
- cursor: pointer;
-}
-
-#weighted-settings .item-container .item-div:hover{
- background-color: rgba(0, 0, 0, 0.1);
-}
-
-#weighted-settings .item-container .item-qty-div{
- display: flex;
- flex-direction: row;
- justify-content: space-between;
- padding: 0.125rem 0.5rem;
- cursor: pointer;
-}
-
-#weighted-settings .item-container .item-qty-div .item-qty-input-wrapper{
- display: flex;
- flex-direction: column;
- justify-content: space-around;
-}
-
-#weighted-settings .item-container .item-qty-div input{
- min-width: unset;
- width: 1.5rem;
- text-align: center;
-}
-
-#weighted-settings .item-container .item-qty-div:hover{
- background-color: rgba(0, 0, 0, 0.1);
-}
-
-#weighted-settings .hints-div, #weighted-settings .locations-div{
- margin-top: 2rem;
-}
-
-#weighted-settings .hints-div h3, #weighted-settings .locations-div h3{
- margin-bottom: 0.5rem;
-}
-
-#weighted-settings .hints-container, #weighted-settings .locations-container{
- display: flex;
- flex-direction: row;
- justify-content: space-between;
-}
-
-#weighted-settings .hints-wrapper, #weighted-settings .locations-wrapper{
- width: calc(50% - 0.5rem);
- font-weight: bold;
-}
-
-#weighted-settings .hints-wrapper .simple-list, #weighted-settings .locations-wrapper .simple-list{
- margin-top: 0.25rem;
- height: 300px;
- font-weight: normal;
-}
-
-#weighted-settings #weighted-settings-button-row{
- display: flex;
- flex-direction: row;
- justify-content: space-between;
- margin-top: 15px;
-}
-
-#weighted-settings code{
- background-color: #d9cd8e;
- border-radius: 4px;
- padding-left: 0.25rem;
- padding-right: 0.25rem;
- color: #000000;
-}
-
-#weighted-settings #user-message{
- display: none;
- width: calc(100% - 8px);
- background-color: #ffe86b;
- border-radius: 4px;
- color: #000000;
- padding: 4px;
- text-align: center;
-}
-
-#weighted-settings #user-message.visible{
- display: block;
- cursor: pointer;
-}
-
-#weighted-settings h1{
- font-size: 2.5rem;
- font-weight: normal;
- border-bottom: 1px solid #ffffff;
- width: 100%;
- margin-bottom: 0.5rem;
- color: #ffffff;
- text-shadow: 1px 1px 4px #000000;
-}
-
-#weighted-settings h2{
- font-size: 2rem;
- font-weight: normal;
- border-bottom: 1px solid #ffffff;
- width: 100%;
- margin-bottom: 0.5rem;
- color: #ffe993;
- text-transform: none;
- text-shadow: 1px 1px 2px #000000;
-}
-
-#weighted-settings h3, #weighted-settings h4, #weighted-settings h5, #weighted-settings h6{
- color: #ffffff;
- text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
- text-transform: none;
-}
-
-#weighted-settings a{
- color: #ffef00;
- cursor: pointer;
-}
-
-#weighted-settings input:not([type]){
- border: 1px solid #000000;
- padding: 3px;
- border-radius: 3px;
- min-width: 150px;
-}
-
-#weighted-settings input:not([type]):focus{
- border: 1px solid #ffffff;
-}
-
-#weighted-settings select{
- border: 1px solid #000000;
- padding: 3px;
- border-radius: 3px;
- min-width: 150px;
- background-color: #ffffff;
-}
-
-#weighted-settings .game-options, #weighted-settings .rom-options{
- display: flex;
- flex-direction: column;
-}
-
-#weighted-settings .simple-list{
- display: flex;
- flex-direction: column;
-
- max-height: 300px;
- overflow-y: auto;
- border: 1px solid #ffffff;
- border-radius: 4px;
-}
-
-#weighted-settings .simple-list .list-row label{
- display: block;
- width: calc(100% - 0.5rem);
- padding: 0.0625rem 0.25rem;
-}
-
-#weighted-settings .simple-list .list-row label:hover{
- background-color: rgba(0, 0, 0, 0.1);
-}
-
-#weighted-settings .simple-list .list-row label input[type=checkbox]{
- margin-right: 0.5rem;
-}
-
-#weighted-settings .invisible{
- display: none;
-}
-
-@media all and (max-width: 1000px), all and (orientation: portrait){
- #weighted-settings .game-options{
- justify-content: flex-start;
- flex-wrap: wrap;
- }
-
- #game-options table label{
- display: block;
- min-width: 200px;
- }
-}
diff --git a/WebHostLib/static/styles/weightedOptions/weightedOptions.css b/WebHostLib/static/styles/weightedOptions/weightedOptions.css
new file mode 100644
index 000000000000..3cfc6d24992d
--- /dev/null
+++ b/WebHostLib/static/styles/weightedOptions/weightedOptions.css
@@ -0,0 +1,232 @@
+html {
+ background-image: url("../../static/backgrounds/grass.png");
+ background-repeat: repeat;
+ background-size: 650px 650px;
+ scroll-padding-top: 90px;
+}
+
+#weighted-options {
+ max-width: 1000px;
+ margin-left: auto;
+ margin-right: auto;
+ background-color: rgba(0, 0, 0, 0.15);
+ border-radius: 8px;
+ padding: 1rem;
+ color: #eeffeb;
+}
+#weighted-options #weighted-options-header h1 {
+ margin-bottom: 0;
+ padding-bottom: 0;
+}
+#weighted-options #weighted-options-header h1:nth-child(2) {
+ font-size: 1.4rem;
+ margin-top: -8px;
+ margin-bottom: 0.5rem;
+}
+#weighted-options .js-warning-banner {
+ width: calc(100% - 1rem);
+ padding: 0.5rem;
+ border-radius: 4px;
+ background-color: #f3f309;
+ color: #000000;
+ margin-bottom: 0.5rem;
+ text-align: center;
+}
+#weighted-options .option-wrapper {
+ width: 100%;
+ margin-bottom: 2rem;
+}
+#weighted-options .option-wrapper .add-option-div {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ margin-bottom: 1rem;
+}
+#weighted-options .option-wrapper .add-option-div button {
+ width: auto;
+ height: auto;
+ margin: 0 0 0 0.15rem;
+ padding: 0 0.25rem;
+ border-radius: 4px;
+ cursor: default;
+}
+#weighted-options .option-wrapper .add-option-div button:active {
+ margin-bottom: 1px;
+}
+#weighted-options p.option-description {
+ margin: 0 0 1rem;
+}
+#weighted-options p.hint-text {
+ margin: 0 0 1rem;
+ font-style: italic;
+}
+#weighted-options table {
+ width: 100%;
+ margin-top: 0.5rem;
+ margin-bottom: 1.5rem;
+}
+#weighted-options table th, #weighted-options table td {
+ border: none;
+}
+#weighted-options table td {
+ padding: 5px;
+}
+#weighted-options table .td-left {
+ font-family: LexendDeca-Regular, sans-serif;
+ padding-right: 1rem;
+ width: 200px;
+}
+#weighted-options table .td-middle {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-evenly;
+ padding-right: 1rem;
+}
+#weighted-options table .td-right {
+ width: 4rem;
+ text-align: right;
+}
+#weighted-options table .td-delete {
+ width: 50px;
+ text-align: right;
+}
+#weighted-options table .range-option-delete {
+ cursor: pointer;
+}
+#weighted-options #weighted-options-button-row {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ margin-top: 15px;
+}
+#weighted-options #user-message {
+ display: none;
+ width: calc(100% - 8px);
+ background-color: #ffe86b;
+ border-radius: 4px;
+ color: #000000;
+ padding: 4px;
+ text-align: center;
+}
+#weighted-options #user-message.visible {
+ display: block;
+ cursor: pointer;
+}
+#weighted-options h1 {
+ font-size: 2.5rem;
+ font-weight: normal;
+ width: 100%;
+ margin-bottom: 0.5rem;
+ color: #ffffff;
+ text-shadow: 1px 1px 4px #000000;
+}
+#weighted-options h2, #weighted-options details summary.h2 {
+ font-size: 2rem;
+ font-weight: normal;
+ border-bottom: 1px solid #ffffff;
+ width: 100%;
+ margin-bottom: 0.5rem;
+ color: #ffe993;
+ text-transform: none;
+ text-shadow: 1px 1px 2px #000000;
+}
+#weighted-options h3, #weighted-options h4, #weighted-options h5, #weighted-options h6 {
+ color: #ffffff;
+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
+ text-transform: none;
+ cursor: unset;
+}
+#weighted-options h3.option-group-header {
+ margin-top: 0.75rem;
+ font-weight: bold;
+}
+#weighted-options a {
+ color: #ffef00;
+ cursor: pointer;
+}
+#weighted-options input:not([type]) {
+ border: 1px solid #000000;
+ padding: 3px;
+ border-radius: 3px;
+ min-width: 150px;
+}
+#weighted-options input:not([type]):focus {
+ border: 1px solid #ffffff;
+}
+#weighted-options .invisible {
+ display: none;
+}
+#weighted-options .unsupported-option {
+ margin-top: 0.5rem;
+}
+#weighted-options .set-container, #weighted-options .dict-container, #weighted-options .list-container {
+ display: flex;
+ flex-direction: column;
+ background-color: rgba(0, 0, 0, 0.25);
+ border: 1px solid rgba(20, 20, 20, 0.25);
+ border-radius: 3px;
+ color: #ffffff;
+ max-height: 15rem;
+ min-width: 14.5rem;
+ overflow-y: auto;
+ padding-right: 0.25rem;
+ padding-left: 0.25rem;
+ margin-top: 0.5rem;
+}
+#weighted-options .set-container .divider, #weighted-options .dict-container .divider, #weighted-options .list-container .divider {
+ width: 100%;
+ height: 2px;
+ background-color: rgba(20, 20, 20, 0.25);
+ margin-top: 0.125rem;
+ margin-bottom: 0.125rem;
+}
+#weighted-options .set-container .set-entry, #weighted-options .set-container .dict-entry, #weighted-options .set-container .list-entry, #weighted-options .dict-container .set-entry, #weighted-options .dict-container .dict-entry, #weighted-options .dict-container .list-entry, #weighted-options .list-container .set-entry, #weighted-options .list-container .dict-entry, #weighted-options .list-container .list-entry {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ padding-bottom: 0.25rem;
+ padding-top: 0.25rem;
+ user-select: none;
+ line-height: 1rem;
+}
+#weighted-options .set-container .set-entry:hover, #weighted-options .set-container .dict-entry:hover, #weighted-options .set-container .list-entry:hover, #weighted-options .dict-container .set-entry:hover, #weighted-options .dict-container .dict-entry:hover, #weighted-options .dict-container .list-entry:hover, #weighted-options .list-container .set-entry:hover, #weighted-options .list-container .dict-entry:hover, #weighted-options .list-container .list-entry:hover {
+ background-color: rgba(20, 20, 20, 0.25);
+}
+#weighted-options .set-container .set-entry input[type=checkbox], #weighted-options .set-container .dict-entry input[type=checkbox], #weighted-options .set-container .list-entry input[type=checkbox], #weighted-options .dict-container .set-entry input[type=checkbox], #weighted-options .dict-container .dict-entry input[type=checkbox], #weighted-options .dict-container .list-entry input[type=checkbox], #weighted-options .list-container .set-entry input[type=checkbox], #weighted-options .list-container .dict-entry input[type=checkbox], #weighted-options .list-container .list-entry input[type=checkbox] {
+ margin-right: 0.25rem;
+}
+#weighted-options .set-container .set-entry input[type=number], #weighted-options .set-container .dict-entry input[type=number], #weighted-options .set-container .list-entry input[type=number], #weighted-options .dict-container .set-entry input[type=number], #weighted-options .dict-container .dict-entry input[type=number], #weighted-options .dict-container .list-entry input[type=number], #weighted-options .list-container .set-entry input[type=number], #weighted-options .list-container .dict-entry input[type=number], #weighted-options .list-container .list-entry input[type=number] {
+ max-width: 1.5rem;
+ max-height: 1rem;
+ margin-left: 0.125rem;
+ text-align: center;
+ /* Hide arrows on input[type=number] fields */
+ -moz-appearance: textfield;
+}
+#weighted-options .set-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .set-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .set-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .list-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .list-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .list-entry input[type=number]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+#weighted-options .set-container .set-entry label, #weighted-options .set-container .dict-entry label, #weighted-options .set-container .list-entry label, #weighted-options .dict-container .set-entry label, #weighted-options .dict-container .dict-entry label, #weighted-options .dict-container .list-entry label, #weighted-options .list-container .set-entry label, #weighted-options .list-container .dict-entry label, #weighted-options .list-container .list-entry label {
+ flex-grow: 1;
+ margin-right: 0;
+ min-width: unset;
+ display: unset;
+}
+
+.hidden {
+ display: none;
+}
+
+@media all and (max-width: 1000px), all and (orientation: portrait) {
+ #weighted-options .game-options {
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ }
+ #game-options table label {
+ display: block;
+ min-width: 200px;
+ }
+}
+
+/*# sourceMappingURL=weightedOptions.css.map */
diff --git a/WebHostLib/static/styles/weightedOptions/weightedOptions.css.map b/WebHostLib/static/styles/weightedOptions/weightedOptions.css.map
new file mode 100644
index 000000000000..7c57cde01506
--- /dev/null
+++ b/WebHostLib/static/styles/weightedOptions/weightedOptions.css.map
@@ -0,0 +1 @@
+{"version":3,"sourceRoot":"","sources":["weightedOptions.scss"],"names":[],"mappings":"AAAA;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGI;EACI;EACA;;AAGJ;EACI;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAOZ;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;;AAIR;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAIA;EACI;EACA;;AAIR;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;AAEA;EACA;;AACA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;;AAMhB;EACI;;;AAGJ;EACI;IACI;IACA;;EAGJ;IACI;IACA","file":"weightedOptions.css"}
\ No newline at end of file
diff --git a/WebHostLib/static/styles/weightedOptions/weightedOptions.scss b/WebHostLib/static/styles/weightedOptions/weightedOptions.scss
new file mode 100644
index 000000000000..7ff3a2c3722e
--- /dev/null
+++ b/WebHostLib/static/styles/weightedOptions/weightedOptions.scss
@@ -0,0 +1,274 @@
+html{
+ background-image: url('../../static/backgrounds/grass.png');
+ background-repeat: repeat;
+ background-size: 650px 650px;
+ scroll-padding-top: 90px;
+}
+
+#weighted-options{
+ max-width: 1000px;
+ margin-left: auto;
+ margin-right: auto;
+ background-color: rgba(0, 0, 0, 0.15);
+ border-radius: 8px;
+ padding: 1rem;
+ color: #eeffeb;
+
+ #weighted-options-header{
+ h1{
+ margin-bottom: 0;
+ padding-bottom: 0;
+ }
+
+ h1:nth-child(2){
+ font-size: 1.4rem;
+ margin-top: -8px;
+ margin-bottom: 0.5rem;
+ }
+ }
+
+ .js-warning-banner{
+ width: calc(100% - 1rem);
+ padding: 0.5rem;
+ border-radius: 4px;
+ background-color: #f3f309;
+ color: #000000;
+ margin-bottom: 0.5rem;
+ text-align: center;
+ }
+
+ .option-wrapper{
+ width: 100%;
+ margin-bottom: 2rem;
+
+ .add-option-div{
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ margin-bottom: 1rem;
+
+ button{
+ width: auto;
+ height: auto;
+ margin: 0 0 0 0.15rem;
+ padding: 0 0.25rem;
+ border-radius: 4px;
+ cursor: default;
+
+ &:active{
+ margin-bottom: 1px;
+ }
+ }
+ }
+ }
+
+ p{
+ &.option-description{
+ margin: 0 0 1rem;
+ }
+
+ &.hint-text{
+ margin: 0 0 1rem;
+ font-style: italic;
+ };
+ }
+
+ table{
+ width: 100%;
+ margin-top: 0.5rem;
+ margin-bottom: 1.5rem;
+
+ th, td{
+ border: none;
+ }
+
+ td{
+ padding: 5px;
+ }
+
+ .td-left{
+ font-family: LexendDeca-Regular, sans-serif;
+ padding-right: 1rem;
+ width: 200px;
+ }
+
+ .td-middle{
+ display: flex;
+ flex-direction: column;
+ justify-content: space-evenly;
+ padding-right: 1rem;
+ }
+
+ .td-right{
+ width: 4rem;
+ text-align: right;
+ }
+
+ .td-delete{
+ width: 50px;
+ text-align: right;
+ }
+
+ .range-option-delete{
+ cursor: pointer;
+ }
+ }
+
+ #weighted-options-button-row{
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ margin-top: 15px;
+ }
+
+ #user-message{
+ display: none;
+ width: calc(100% - 8px);
+ background-color: #ffe86b;
+ border-radius: 4px;
+ color: #000000;
+ padding: 4px;
+ text-align: center;
+
+ &.visible{
+ display: block;
+ cursor: pointer;
+ }
+ }
+
+ h1{
+ font-size: 2.5rem;
+ font-weight: normal;
+ width: 100%;
+ margin-bottom: 0.5rem;
+ color: #ffffff;
+ text-shadow: 1px 1px 4px #000000;
+ }
+
+ h2, details summary.h2{
+ font-size: 2rem;
+ font-weight: normal;
+ border-bottom: 1px solid #ffffff;
+ width: 100%;
+ margin-bottom: 0.5rem;
+ color: #ffe993;
+ text-transform: none;
+ text-shadow: 1px 1px 2px #000000;
+ }
+
+ h3, h4, h5, h6{
+ color: #ffffff;
+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
+ text-transform: none;
+ cursor: unset;
+ }
+
+ h3{
+ &.option-group-header{
+ margin-top: 0.75rem;
+ font-weight: bold;
+ }
+ }
+
+ a{
+ color: #ffef00;
+ cursor: pointer;
+ }
+
+ input:not([type]){
+ border: 1px solid #000000;
+ padding: 3px;
+ border-radius: 3px;
+ min-width: 150px;
+
+ &:focus{
+ border: 1px solid #ffffff;
+ }
+ }
+
+ .invisible{
+ display: none;
+ }
+
+ .unsupported-option{
+ margin-top: 0.5rem;
+ }
+
+ .set-container, .dict-container, .list-container{
+ display: flex;
+ flex-direction: column;
+ background-color: rgba(0, 0, 0, 0.25);
+ border: 1px solid rgba(20, 20, 20, 0.25);
+ border-radius: 3px;
+ color: #ffffff;
+ max-height: 15rem;
+ min-width: 14.5rem;
+ overflow-y: auto;
+ padding-right: 0.25rem;
+ padding-left: 0.25rem;
+ margin-top: 0.5rem;
+
+ .divider{
+ width: 100%;
+ height: 2px;
+ background-color: rgba(20, 20, 20, 0.25);
+ margin-top: 0.125rem;
+ margin-bottom: 0.125rem;
+ }
+
+ .set-entry, .dict-entry, .list-entry{
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ padding-bottom: 0.25rem;
+ padding-top: 0.25rem;
+ user-select: none;
+ line-height: 1rem;
+
+ &:hover{
+ background-color: rgba(20, 20, 20, 0.25);
+ }
+
+ input[type=checkbox]{
+ margin-right: 0.25rem;
+ }
+
+ input[type=number]{
+ max-width: 1.5rem;
+ max-height: 1rem;
+ margin-left: 0.125rem;
+ text-align: center;
+
+ /* Hide arrows on input[type=number] fields */
+ -moz-appearance: textfield;
+ &::-webkit-outer-spin-button, &::-webkit-inner-spin-button{
+ -webkit-appearance: none;
+ margin: 0;
+ }
+ }
+
+ label{
+ flex-grow: 1;
+ margin-right: 0;
+ min-width: unset;
+ display: unset;
+ }
+ }
+ }
+}
+
+.hidden{
+ display: none;
+}
+
+@media all and (max-width: 1000px), all and (orientation: portrait){
+ #weighted-options .game-options{
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ }
+
+ #game-options table label{
+ display: block;
+ min-width: 200px;
+ }
+}
diff --git a/WebHostLib/stats.py b/WebHostLib/stats.py
index 36545ac96f1f..2ce25c2cc7a2 100644
--- a/WebHostLib/stats.py
+++ b/WebHostLib/stats.py
@@ -1,4 +1,3 @@
-import typing
from collections import Counter, defaultdict
from colorsys import hsv_to_rgb
from datetime import datetime, timedelta, date
@@ -18,21 +17,23 @@
PLOT_WIDTH = 600
-def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
- typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
- games_played = defaultdict(Counter)
- total_games = Counter()
+def get_db_data(known_games: set[str]) -> tuple[Counter[str], defaultdict[date, dict[str, int]]]:
+ games_played: defaultdict[date, dict[str, int]] = defaultdict(Counter)
+ total_games: Counter[str] = Counter()
cutoff = date.today() - timedelta(days=30)
room: Room
for room in select(room for room in Room if room.creation_time >= cutoff):
for slot in room.seed.slots:
if slot.game in known_games:
- total_games[slot.game] += 1
- games_played[room.creation_time.date()][slot.game] += 1
+ current_game = slot.game
+ else:
+ current_game = "Other"
+ total_games[current_game] += 1
+ games_played[room.creation_time.date()][current_game] += 1
return total_games, games_played
-def get_color_palette(colors_needed: int) -> typing.List[RGB]:
+def get_color_palette(colors_needed: int) -> list[RGB]:
colors = []
# colors_needed +1 to prevent first and last color being too close to each other
colors_needed += 1
@@ -47,8 +48,7 @@ def get_color_palette(colors_needed: int) -> typing.List[RGB]:
return colors
-def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.Dict[str, int]],
- game: str, color: RGB) -> figure:
+def create_game_played_figure(all_games_data: dict[date, dict[str, int]], game: str, color: RGB) -> figure:
occurences = []
days = [day for day, game_data in all_games_data.items() if game_data[game]]
for day in days:
@@ -84,7 +84,7 @@ def stats():
days = sorted(games_played)
color_palette = get_color_palette(len(total_games))
- game_to_color: typing.Dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
+ game_to_color: dict[str, RGB] = {game: color for game, color in zip(total_games, color_palette)}
for game in sorted(total_games):
occurences = []
diff --git a/WebHostLib/templates/404.html b/WebHostLib/templates/404.html
index 9d567510eeef..6c91fed4ac89 100644
--- a/WebHostLib/templates/404.html
+++ b/WebHostLib/templates/404.html
@@ -1,5 +1,6 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
+{% set show_footer = True %}
{% block head %}
Page Not Found (404)
@@ -13,5 +14,4 @@ This page is out of logic!
The page you're looking for doesn't exist.
Click here to return to safety.
- {% include 'islandFooter.html' %}
{% endblock %}
diff --git a/WebHostLib/templates/check.html b/WebHostLib/templates/check.html
index 04b51340b513..8a3da7db472a 100644
--- a/WebHostLib/templates/check.html
+++ b/WebHostLib/templates/check.html
@@ -17,9 +17,9 @@ Upload Yaml
- Upload
+ Upload File(s)
diff --git a/WebHostLib/templates/checkResult.html b/WebHostLib/templates/checkResult.html
index c245d7381a4c..75ae7479f5ff 100644
--- a/WebHostLib/templates/checkResult.html
+++ b/WebHostLib/templates/checkResult.html
@@ -28,6 +28,10 @@ Verification Results
{% endfor %}
+ {% if combined_yaml %}
+ Combined File Download
+ Download
+ {% endif %}
{% endblock %}
diff --git a/WebHostLib/templates/checksfinderTracker.html b/WebHostLib/templates/checksfinderTracker.html
deleted file mode 100644
index 5df77f5e74d0..000000000000
--- a/WebHostLib/templates/checksfinderTracker.html
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
- {{ player_name }}'s Tracker
-
-
-
-
-
-
-
-
diff --git a/WebHostLib/templates/faq.html b/WebHostLib/templates/faq.html
deleted file mode 100644
index 76bdb96d2ef8..000000000000
--- a/WebHostLib/templates/faq.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{% extends 'pageWrapper.html' %}
-
-{% block head %}
- {% include 'header/grassHeader.html' %}
- Frequently Asked Questions
-
-
-
-{% endblock %}
-
-{% block body %}
-
-
-
-{% endblock %}
diff --git a/WebHostLib/templates/gameInfo.html b/WebHostLib/templates/gameInfo.html
deleted file mode 100644
index c5ebba82848d..000000000000
--- a/WebHostLib/templates/gameInfo.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{% extends 'pageWrapper.html' %}
-
-{% block head %}
- {{ game }} Info
-
-
-
-{% endblock %}
-
-{% block body %}
- {% include 'header/'+theme+'Header.html' %}
-
-
-
-{% endblock %}
diff --git a/WebHostLib/templates/generate.html b/WebHostLib/templates/generate.html
index dd25a908049d..53d98dfae6ba 100644
--- a/WebHostLib/templates/generate.html
+++ b/WebHostLib/templates/generate.html
@@ -69,8 +69,8 @@ Generate Game{% if race %} (Race Mode){% endif %}
- Allow !collect after goal completion
Automatic on goal completion
+ Allow !collect after goal completion
Automatic on goal completion and manual !collect
@@ -93,9 +93,9 @@ Generate Game{% if race %} (Race Mode){% endif %}
{% if race -%}
Disabled in Race mode
{%- else -%}
- Disabled
Allow !remaining after goal completion
Manual !remaining
+ Disabled
{%- endif -%}
@@ -185,12 +185,12 @@ Generate Game{% if race %} (Race Mode){% endif %}
+
+ Items
+
Bosses
-
- Items
-
Connections
@@ -203,10 +203,10 @@ Generate Game{% if race %} (Race Mode){% endif %}
-
+
- Upload File
+ Upload File(s)
diff --git a/WebHostLib/templates/genericTracker.html b/WebHostLib/templates/genericTracker.html
index 1c2fcd44c0dd..2598aa12194b 100644
--- a/WebHostLib/templates/genericTracker.html
+++ b/WebHostLib/templates/genericTracker.html
@@ -1,36 +1,57 @@
-{% extends 'tablepage.html' %}
+{% extends "tablepage.html" %}
{% block head %}
{{ super() }}
{{ player_name }}'s Tracker
-
-
-
+
+
+
{% endblock %}
{% block body %}
- {% include 'header/dirtHeader.html' %}
-
+ {% include "header/dirtHeader.html" %}
+
+
+
+
+
Item
Amount
- Order Received
+ Last Order Received
- {% for id, count in inventory.items() %}
-
- {{ id | item_name }}
- {{ count }}
- {{received_items[id]}}
-
+ {% for id, count in inventory.items() if count > 0 %}
+
+ {{ item_id_to_name[game][id] }}
+ {{ count }}
+ {{ received_items[id] }}
+
{%- endfor -%}
@@ -39,24 +60,70 @@
-
- Location
- Checked
-
+
+ Location
+ Checked
+
- {% for name in checked_locations %}
+
+ {%- for location in locations -%}
+
+ {{ location_id_to_name[game][location] }}
+
+ {% if location in checked_locations %}â{% endif %}
+
+
+ {%- endfor -%}
+
+
+
+
+
+
+
- {{ name | location_name}}
- â
+ Finder
+ Receiver
+ Item
+ Location
+ Game
+ Entrance
+ Found
- {%- endfor -%}
- {% for name in not_checked_locations %}
+
+
+ {%- for hint in hints -%}
- {{ name | location_name}}
-
+
+ {% if hint.finding_player == player %}
+ {{ player_names_with_alias[(team, hint.finding_player)] }}
+ {% elif get_slot_info(hint.finding_player).type == 2 %}
+ {{ player_names_with_alias[(team, hint.finding_player)] }}
+ {% else %}
+
+ {{ player_names_with_alias[(team, hint.finding_player)] }}
+
+ {% endif %}
+
+
+ {% if hint.receiving_player == player %}
+ {{ player_names_with_alias[(team, hint.receiving_player)] }}
+ {% elif get_slot_info(hint.receiving_player).type == 2 %}
+ {{ player_names_with_alias[(team, hint.receiving_player)] }}
+ {% else %}
+
+ {{ player_names_with_alias[(team, hint.receiving_player)] }}
+
+ {% endif %}
+
+ {{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}
+ {{ location_id_to_name[games[(team, hint.finding_player)]][hint.location] }}
+ {{ games[(team, hint.finding_player)] }}
+ {% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}
+ {% if hint.found %}â{% endif %}
- {%- endfor -%}
+ {%- endfor -%}
diff --git a/WebHostLib/templates/glossary.html b/WebHostLib/templates/glossary.html
deleted file mode 100644
index 921f678157fc..000000000000
--- a/WebHostLib/templates/glossary.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{% extends 'pageWrapper.html' %}
-
-{% block head %}
- {% include 'header/grassHeader.html' %}
- Glossary
-
-
-
-{% endblock %}
-
-{% block body %}
-
-
-
-{% endblock %}
diff --git a/WebHostLib/templates/hintTable.html b/WebHostLib/templates/hintTable.html
deleted file mode 100644
index 00b74111ea51..000000000000
--- a/WebHostLib/templates/hintTable.html
+++ /dev/null
@@ -1,28 +0,0 @@
-{% for team, hints in hints.items() %}
-
-
-
-
- Finder
- Receiver
- Item
- Location
- Entrance
- Found
-
-
-
- {%- for hint in hints -%}
-
- {{ long_player_names[team, hint.finding_player] }}
- {{ long_player_names[team, hint.receiving_player] }}
- {{ hint.item|item_name }}
- {{ hint.location|location_name }}
- {% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}
- {% if hint.found %}â{% endif %}
-
- {%- endfor -%}
-
-
-
-{% endfor %}
\ No newline at end of file
diff --git a/WebHostLib/templates/hostGame.html b/WebHostLib/templates/hostGame.html
index 2bcb993af572..d7d0a9633129 100644
--- a/WebHostLib/templates/hostGame.html
+++ b/WebHostLib/templates/hostGame.html
@@ -1,4 +1,5 @@
{% extends 'pageWrapper.html' %}
+{% set show_footer = True %}
{% block head %}
Upload Multidata
@@ -27,6 +28,4 @@ Host Game
-
- {% include 'islandFooter.html' %}
{% endblock %}
diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html
index ba15d64acac1..10ff5e84470a 100644
--- a/WebHostLib/templates/hostRoom.html
+++ b/WebHostLib/templates/hostRoom.html
@@ -3,66 +3,201 @@
{% block head %}
Multiworld {{ room.id|suuid }}
{% if should_refresh %} {% endif %}
+
+
+
+ {% if room.seed.slots|length < 2 %}
+
+ {% else %}
+
+ {% endif %}
{% endblock %}
{% block body %}
{% include 'header/grassHeader.html' %}
- {% if room.owner == session["_id"] %}
- Room created from
Seed #{{ room.seed.id|suuid }}
-
- {% endif %}
- {% if room.tracker %}
- This room has a
Multiworld Tracker enabled.
-
- {% endif %}
- The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
- Should you wish to continue later,
- anyone can simply refresh this page and the server will resume.
- {% if room.last_port == -1 %}
- There was an error hosting this Room. Another attempt will be made on refreshing this page.
- 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
- '/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
-
- in the
client .
- {% endif %}
+
+ {% if room.owner == session["_id"] %}
+ Room created from Seed #{{ room.seed.id|suuid }}
+
+ {% endif %}
+ {% if room.tracker %}
+ This room has a Multiworld Tracker
+ and a Sphere Tracker enabled.
+
+ {% endif %}
+ The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
+ Should you wish to continue later,
+ anyone can simply refresh this page and the server will resume.
+ {% if room.last_port == -1 %}
+ There was an error hosting this Room. Another attempt will be made on refreshing this page.
+ 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
+ '/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
+
+ in the client .
+ {% endif %}
+
{{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %}
-
-
{% endif %}
+
{% endblock %}
diff --git a/WebHostLib/templates/islandFooter.html b/WebHostLib/templates/islandFooter.html
index 7b89c4a9e079..7de14f0d827c 100644
--- a/WebHostLib/templates/islandFooter.html
+++ b/WebHostLib/templates/islandFooter.html
@@ -1,6 +1,6 @@
{% block footer %}